@tekmidian/pai 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,17 +2,18 @@
2
2
  /**
3
3
  * PreCompact Hook - Triggered before context compression
4
4
  *
5
- * Two critical jobs:
6
- * 1. Save checkpoint to session note + send notification (existing)
7
- * 2. OUTPUT session state to stdout so it gets injected into the conversation
8
- * as a <system-reminder> BEFORE compaction. This ensures the compaction
9
- * summary retains awareness of what was being worked on.
5
+ * Three critical jobs:
6
+ * 1. Save rich checkpoint to session note work items, state, meaningful rename
7
+ * 2. Update TODO.md with a proper ## Continue section for the next session
8
+ * 3. Save session state to temp file for post-compact injection via SessionStart(compact)
10
9
  *
11
- * Without (2), compaction produces a generic summary and the session loses
12
- * critical context: current task, recent requests, file paths, decisions.
10
+ * Uses a CUMULATIVE state file (.compact-state.json) that persists across
11
+ * compactions. This ensures that even after multiple compactions (where the
12
+ * transcript becomes thin), we still have rich data for titles, summaries,
13
+ * and work items from earlier in the session.
13
14
  */
14
15
 
15
- import { readFileSync, writeFileSync } from 'fs';
16
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
16
17
  import { basename, dirname, join } from 'path';
17
18
  import { tmpdir } from 'os';
18
19
  import {
@@ -22,8 +23,8 @@ import {
22
23
  appendCheckpoint,
23
24
  addWorkToSessionNote,
24
25
  findNotesDir,
25
- findTodoPath,
26
- addTodoCheckpoint,
26
+ renameSessionNote,
27
+ updateTodoContinue,
27
28
  calculateSessionTokens,
28
29
  WorkItem,
29
30
  } from '../lib/project-utils';
@@ -37,6 +38,16 @@ interface HookInput {
37
38
  trigger?: string;
38
39
  }
39
40
 
41
+ /** Structured data extracted from a transcript in a single pass. */
42
+ interface TranscriptData {
43
+ userMessages: string[];
44
+ summaries: string[];
45
+ captures: string[];
46
+ lastCompleted: string;
47
+ filesModified: string[];
48
+ workItems: WorkItem[];
49
+ }
50
+
40
51
  // ---------------------------------------------------------------------------
41
52
  // Helpers
42
53
  // ---------------------------------------------------------------------------
@@ -80,24 +91,23 @@ function getTranscriptStats(transcriptPath: string): { messageCount: number; isL
80
91
  }
81
92
 
82
93
  // ---------------------------------------------------------------------------
83
- // Session state extraction
94
+ // Unified transcript parser — single pass extracts everything
84
95
  // ---------------------------------------------------------------------------
85
96
 
86
- /**
87
- * Extract structured session state from the transcript JSONL.
88
- * Returns a human-readable summary (<2000 chars) suitable for injection
89
- * into the conversation before compaction.
90
- */
91
- function extractSessionState(transcriptPath: string, cwd?: string): string | null {
97
+ function parseTranscript(transcriptPath: string): TranscriptData {
98
+ const data: TranscriptData = {
99
+ userMessages: [],
100
+ summaries: [],
101
+ captures: [],
102
+ lastCompleted: '',
103
+ filesModified: [],
104
+ workItems: [],
105
+ };
106
+
92
107
  try {
93
108
  const raw = readFileSync(transcriptPath, 'utf-8');
94
109
  const lines = raw.trim().split('\n');
95
-
96
- const userMessages: string[] = [];
97
- const summaries: string[] = [];
98
- const captures: string[] = [];
99
- let lastCompleted = '';
100
- const filesModified = new Set<string>();
110
+ const seenSummaries = new Set<string>();
101
111
 
102
112
  for (const line of lines) {
103
113
  if (!line.trim()) continue;
@@ -107,150 +117,223 @@ function extractSessionState(transcriptPath: string, cwd?: string): string | nul
107
117
  // --- User messages ---
108
118
  if (entry.type === 'user' && entry.message?.content) {
109
119
  const text = contentToText(entry.message.content).slice(0, 300);
110
- if (text) userMessages.push(text);
120
+ if (text) data.userMessages.push(text);
111
121
  }
112
122
 
113
- // --- Assistant structured sections ---
123
+ // --- Assistant content ---
114
124
  if (entry.type === 'assistant' && entry.message?.content) {
115
125
  const text = contentToText(entry.message.content);
116
126
 
127
+ // Summaries → also create work items
117
128
  const summaryMatch = text.match(/SUMMARY:\s*(.+?)(?:\n|$)/i);
118
129
  if (summaryMatch) {
119
130
  const s = summaryMatch[1].trim();
120
- if (s.length > 5 && !summaries.includes(s)) summaries.push(s);
131
+ if (s.length > 5 && !data.summaries.includes(s)) {
132
+ data.summaries.push(s);
133
+ if (!seenSummaries.has(s)) {
134
+ seenSummaries.add(s);
135
+ const details: string[] = [];
136
+ const actionsMatch = text.match(/ACTIONS:\s*(.+?)(?=\n[A-Z]+:|$)/is);
137
+ if (actionsMatch) {
138
+ const actionLines = actionsMatch[1].split('\n')
139
+ .map(l => l.replace(/^[-*•]\s*/, '').replace(/^\d+\.\s*/, '').trim())
140
+ .filter(l => l.length > 3 && l.length < 100);
141
+ details.push(...actionLines.slice(0, 3));
142
+ }
143
+ data.workItems.push({ title: s, details: details.length > 0 ? details : undefined, completed: true });
144
+ }
145
+ }
121
146
  }
122
147
 
148
+ // Captures
123
149
  const captureMatch = text.match(/CAPTURE:\s*(.+?)(?:\n|$)/i);
124
150
  if (captureMatch) {
125
151
  const c = captureMatch[1].trim();
126
- if (c.length > 5 && !captures.includes(c)) captures.push(c);
152
+ if (c.length > 5 && !data.captures.includes(c)) data.captures.push(c);
127
153
  }
128
154
 
155
+ // Completed
129
156
  const completedMatch = text.match(/COMPLETED:\s*(.+?)(?:\n|$)/i);
130
157
  if (completedMatch) {
131
- lastCompleted = completedMatch[1].trim().replace(/\*+/g, '');
158
+ data.lastCompleted = completedMatch[1].trim().replace(/\*+/g, '');
159
+ if (data.workItems.length === 0 && !seenSummaries.has(data.lastCompleted) && data.lastCompleted.length > 5) {
160
+ seenSummaries.add(data.lastCompleted);
161
+ data.workItems.push({ title: data.lastCompleted, completed: true });
162
+ }
132
163
  }
133
- }
134
164
 
135
- // --- Tool use: file modifications ---
136
- if (entry.type === 'assistant' && entry.message?.content && Array.isArray(entry.message.content)) {
137
- for (const block of entry.message.content) {
138
- if (block.type === 'tool_use') {
139
- const tool = block.name;
140
- if ((tool === 'Edit' || tool === 'Write') && block.input?.file_path) {
141
- filesModified.add(block.input.file_path);
165
+ // File modifications (from tool_use blocks)
166
+ if (Array.isArray(entry.message.content)) {
167
+ for (const block of entry.message.content) {
168
+ if (block.type === 'tool_use') {
169
+ const tool = block.name;
170
+ if ((tool === 'Edit' || tool === 'Write') && block.input?.file_path) {
171
+ if (!data.filesModified.includes(block.input.file_path)) {
172
+ data.filesModified.push(block.input.file_path);
173
+ }
174
+ }
142
175
  }
143
176
  }
144
177
  }
145
178
  }
146
179
  }
180
+ } catch (err) {
181
+ console.error(`parseTranscript error: ${err}`);
182
+ }
147
183
 
148
- // Build the output — keep it concise
149
- const parts: string[] = [];
184
+ return data;
185
+ }
150
186
 
151
- if (cwd) {
152
- parts.push(`Working directory: ${cwd}`);
153
- }
187
+ // ---------------------------------------------------------------------------
188
+ // Format session state as human-readable string
189
+ // ---------------------------------------------------------------------------
154
190
 
155
- // Last 3 user messages
156
- const recentUser = userMessages.slice(-3);
157
- if (recentUser.length > 0) {
158
- parts.push('\nRecent user requests:');
159
- for (const msg of recentUser) {
160
- // Trim to first line or 200 chars
161
- const firstLine = msg.split('\n')[0].slice(0, 200);
162
- parts.push(`- ${firstLine}`);
163
- }
164
- }
191
+ function formatSessionState(data: TranscriptData, cwd?: string): string | null {
192
+ const parts: string[] = [];
165
193
 
166
- // Summaries (last 3)
167
- const recentSummaries = summaries.slice(-3);
168
- if (recentSummaries.length > 0) {
169
- parts.push('\nWork summaries:');
170
- for (const s of recentSummaries) {
171
- parts.push(`- ${s.slice(0, 150)}`);
172
- }
173
- }
194
+ if (cwd) parts.push(`Working directory: ${cwd}`);
174
195
 
175
- // Captures (last 5)
176
- const recentCaptures = captures.slice(-5);
177
- if (recentCaptures.length > 0) {
178
- parts.push('\nCaptured context:');
179
- for (const c of recentCaptures) {
180
- parts.push(`- ${c.slice(0, 150)}`);
181
- }
196
+ const recentUser = data.userMessages.slice(-3);
197
+ if (recentUser.length > 0) {
198
+ parts.push('\nRecent user requests:');
199
+ for (const msg of recentUser) {
200
+ parts.push(`- ${msg.split('\n')[0].slice(0, 200)}`);
182
201
  }
202
+ }
183
203
 
184
- // Files modified (last 10)
185
- const files = Array.from(filesModified).slice(-10);
186
- if (files.length > 0) {
187
- parts.push('\nFiles modified this session:');
188
- for (const f of files) {
189
- parts.push(`- ${f}`);
190
- }
191
- }
204
+ const recentSummaries = data.summaries.slice(-3);
205
+ if (recentSummaries.length > 0) {
206
+ parts.push('\nWork summaries:');
207
+ for (const s of recentSummaries) parts.push(`- ${s.slice(0, 150)}`);
208
+ }
192
209
 
193
- if (lastCompleted) {
194
- parts.push(`\nLast completed: ${lastCompleted.slice(0, 150)}`);
195
- }
210
+ const recentCaptures = data.captures.slice(-5);
211
+ if (recentCaptures.length > 0) {
212
+ parts.push('\nCaptured context:');
213
+ for (const c of recentCaptures) parts.push(`- ${c.slice(0, 150)}`);
214
+ }
196
215
 
197
- const result = parts.join('\n');
198
- return result.length > 50 ? result : null;
199
- } catch (err) {
200
- console.error(`extractSessionState error: ${err}`);
201
- return null;
216
+ const files = data.filesModified.slice(-10);
217
+ if (files.length > 0) {
218
+ parts.push('\nFiles modified this session:');
219
+ for (const f of files) parts.push(`- ${f}`);
220
+ }
221
+
222
+ if (data.lastCompleted) {
223
+ parts.push(`\nLast completed: ${data.lastCompleted.slice(0, 150)}`);
202
224
  }
225
+
226
+ const result = parts.join('\n');
227
+ return result.length > 50 ? result : null;
203
228
  }
204
229
 
205
230
  // ---------------------------------------------------------------------------
206
- // Work item extraction (same pattern as stop-hook.ts)
231
+ // Derive a meaningful title for the session note
207
232
  // ---------------------------------------------------------------------------
208
233
 
209
- function extractWorkFromTranscript(transcriptPath: string): WorkItem[] {
210
- try {
211
- const raw = readFileSync(transcriptPath, 'utf-8');
212
- const lines = raw.trim().split('\n');
213
- const workItems: WorkItem[] = [];
214
- const seenSummaries = new Set<string>();
234
+ function deriveTitle(data: TranscriptData): string {
235
+ let title = '';
215
236
 
216
- for (const line of lines) {
217
- if (!line.trim()) continue;
218
- let entry: any;
219
- try { entry = JSON.parse(line); } catch { continue; }
237
+ // 1. Last work item title (most descriptive of what was accomplished)
238
+ if (data.workItems.length > 0) {
239
+ title = data.workItems[data.workItems.length - 1].title;
240
+ }
241
+ // 2. Last summary
242
+ else if (data.summaries.length > 0) {
243
+ title = data.summaries[data.summaries.length - 1];
244
+ }
245
+ // 3. Last completed marker
246
+ else if (data.lastCompleted && data.lastCompleted.length > 5) {
247
+ title = data.lastCompleted;
248
+ }
249
+ // 4. Last substantive user message
250
+ else if (data.userMessages.length > 0) {
251
+ for (let i = data.userMessages.length - 1; i >= 0; i--) {
252
+ const msg = data.userMessages[i].split('\n')[0].trim();
253
+ if (msg.length > 10 && msg.length < 80 &&
254
+ !msg.toLowerCase().startsWith('yes') &&
255
+ !msg.toLowerCase().startsWith('ok')) {
256
+ title = msg;
257
+ break;
258
+ }
259
+ }
260
+ }
261
+ // 5. Derive from files modified
262
+ if (!title && data.filesModified.length > 0) {
263
+ const basenames = data.filesModified.slice(-5).map(f => {
264
+ const b = basename(f);
265
+ return b.replace(/\.[^.]+$/, '');
266
+ });
267
+ const unique = [...new Set(basenames)];
268
+ title = unique.length <= 3
269
+ ? `Updated ${unique.join(', ')}`
270
+ : `Modified ${data.filesModified.length} files`;
271
+ }
220
272
 
221
- if (entry.type === 'assistant' && entry.message?.content) {
222
- const text = contentToText(entry.message.content);
273
+ // Clean up for filename use
274
+ return title
275
+ .replace(/[^\w\s-]/g, ' ') // Remove special chars
276
+ .replace(/\s+/g, ' ') // Normalize whitespace
277
+ .trim()
278
+ .substring(0, 60);
279
+ }
223
280
 
224
- const summaryMatch = text.match(/SUMMARY:\s*(.+?)(?:\n|$)/i);
225
- if (summaryMatch) {
226
- const summary = summaryMatch[1].trim();
227
- if (summary && !seenSummaries.has(summary) && summary.length > 5) {
228
- seenSummaries.add(summary);
229
- const details: string[] = [];
230
- const actionsMatch = text.match(/ACTIONS:\s*(.+?)(?=\n[A-Z]+:|$)/is);
231
- if (actionsMatch) {
232
- const actionLines = actionsMatch[1].split('\n')
233
- .map(l => l.replace(/^[-*•]\s*/, '').replace(/^\d+\.\s*/, '').trim())
234
- .filter(l => l.length > 3 && l.length < 100);
235
- details.push(...actionLines.slice(0, 3));
236
- }
237
- workItems.push({ title: summary, details: details.length > 0 ? details : undefined, completed: true });
238
- }
239
- }
281
+ // ---------------------------------------------------------------------------
282
+ // Cumulative state — persists across compactions in .compact-state.json
283
+ // ---------------------------------------------------------------------------
240
284
 
241
- const completedMatch = text.match(/COMPLETED:\s*(.+?)(?:\n|$)/i);
242
- if (completedMatch && workItems.length === 0) {
243
- const completed = completedMatch[1].trim().replace(/\*+/g, '');
244
- if (completed && !seenSummaries.has(completed) && completed.length > 5) {
245
- seenSummaries.add(completed);
246
- workItems.push({ title: completed, completed: true });
247
- }
248
- }
249
- }
250
- }
251
- return workItems;
285
+ const CUMULATIVE_STATE_FILE = '.compact-state.json';
286
+
287
+ function loadCumulativeState(notesDir: string): TranscriptData | null {
288
+ try {
289
+ const filePath = join(notesDir, CUMULATIVE_STATE_FILE);
290
+ if (!existsSync(filePath)) return null;
291
+ const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
292
+ return {
293
+ userMessages: raw.userMessages || [],
294
+ summaries: raw.summaries || [],
295
+ captures: raw.captures || [],
296
+ lastCompleted: raw.lastCompleted || '',
297
+ filesModified: raw.filesModified || [],
298
+ workItems: raw.workItems || [],
299
+ };
252
300
  } catch {
253
- return [];
301
+ return null;
302
+ }
303
+ }
304
+
305
+ function mergeTranscriptData(accumulated: TranscriptData | null, current: TranscriptData): TranscriptData {
306
+ if (!accumulated) return current;
307
+
308
+ const mergeArrays = (a: string[], b: string[]): string[] => {
309
+ const seen = new Set(a);
310
+ return [...a, ...b.filter(x => !seen.has(x))];
311
+ };
312
+
313
+ const seenTitles = new Set(accumulated.workItems.map(w => w.title));
314
+ const newWorkItems = current.workItems.filter(w => !seenTitles.has(w.title));
315
+
316
+ return {
317
+ userMessages: mergeArrays(accumulated.userMessages, current.userMessages).slice(-20),
318
+ summaries: mergeArrays(accumulated.summaries, current.summaries),
319
+ captures: mergeArrays(accumulated.captures, current.captures),
320
+ lastCompleted: current.lastCompleted || accumulated.lastCompleted,
321
+ filesModified: mergeArrays(accumulated.filesModified, current.filesModified),
322
+ workItems: [...accumulated.workItems, ...newWorkItems],
323
+ };
324
+ }
325
+
326
+ function saveCumulativeState(notesDir: string, data: TranscriptData, notePath: string | null): void {
327
+ try {
328
+ const filePath = join(notesDir, CUMULATIVE_STATE_FILE);
329
+ writeFileSync(filePath, JSON.stringify({
330
+ ...data,
331
+ notePath,
332
+ lastUpdated: new Date().toISOString(),
333
+ }, null, 2));
334
+ console.error(`Cumulative state saved (${data.workItems.length} work items, ${data.filesModified.length} files)`);
335
+ } catch (err) {
336
+ console.error(`Failed to save cumulative state: ${err}`);
254
337
  }
255
338
  }
256
339
 
@@ -289,23 +372,42 @@ async function main() {
289
372
  : String(tokenCount);
290
373
 
291
374
  // -----------------------------------------------------------------
292
- // Persist session state to numbered session note (like "pause session")
375
+ // Single-pass transcript parsing + cumulative state merge
293
376
  // -----------------------------------------------------------------
294
- const state = extractSessionState(hookInput.transcript_path, hookInput.cwd);
377
+ const data = parseTranscript(hookInput.transcript_path);
295
378
 
379
+ // Find notes directory early — needed for cumulative state
380
+ let notesInfo: { path: string; isLocal: boolean };
296
381
  try {
297
- // Find notes dir — prefer local, fallback to central
298
- const notesInfo = hookInput.cwd
382
+ notesInfo = hookInput.cwd
299
383
  ? findNotesDir(hookInput.cwd)
300
384
  : { path: join(dirname(hookInput.transcript_path), 'Notes'), isLocal: false };
301
- let notePath = getCurrentNotePath(notesInfo.path);
385
+ } catch {
386
+ notesInfo = { path: join(dirname(hookInput.transcript_path), 'Notes'), isLocal: false };
387
+ }
388
+
389
+ // Load accumulated state from previous compactions and merge
390
+ const accumulated = loadCumulativeState(notesInfo.path);
391
+ const merged = mergeTranscriptData(accumulated, data);
392
+ const state = formatSessionState(merged, hookInput.cwd);
393
+
394
+ if (accumulated) {
395
+ console.error(`Loaded cumulative state: ${accumulated.workItems.length} work items, ${accumulated.filesModified.length} files from previous compaction(s)`);
396
+ }
397
+
398
+ // -----------------------------------------------------------------
399
+ // Persist session state to numbered session note (like "pause session")
400
+ // -----------------------------------------------------------------
401
+ let notePath: string | null = null;
402
+
403
+ try {
404
+ notePath = getCurrentNotePath(notesInfo.path);
302
405
 
303
406
  // If no note found, or the latest note is completed, create a new one
304
407
  if (!notePath) {
305
408
  console.error('No session note found — creating one for checkpoint');
306
409
  notePath = createSessionNote(notesInfo.path, 'Recovered Session');
307
410
  } else {
308
- // Check if the found note is already completed — don't write to completed notes
309
411
  try {
310
412
  const noteContent = readFileSync(notePath, 'utf-8');
311
413
  if (noteContent.includes('**Status:** Completed') || noteContent.includes('**Completed:**')) {
@@ -321,11 +423,29 @@ async function main() {
321
423
  : `Context compression triggered at ~${tokenDisplay} tokens with ${stats.messageCount} messages.`;
322
424
  appendCheckpoint(notePath, checkpointBody);
323
425
 
324
- // 2. Write work items to "Work Done" section (same as stop-hook)
325
- const workItems = extractWorkFromTranscript(hookInput.transcript_path);
326
- if (workItems.length > 0) {
327
- addWorkToSessionNote(notePath, workItems, `Pre-Compact (~${tokenDisplay} tokens)`);
328
- console.error(`Added ${workItems.length} work item(s) to session note`);
426
+ // 2. Write work items to "Work Done" section (uses merged cumulative data)
427
+ if (merged.workItems.length > 0) {
428
+ addWorkToSessionNote(notePath, merged.workItems, `Pre-Compact (~${tokenDisplay} tokens)`);
429
+ console.error(`Added ${merged.workItems.length} work item(s) to session note`);
430
+ }
431
+
432
+ // 3. Rename session note with a meaningful title (uses merged data for richer titles)
433
+ const title = deriveTitle(merged);
434
+ if (title) {
435
+ const newPath = renameSessionNote(notePath, title);
436
+ if (newPath !== notePath) {
437
+ // Update H1 title inside the note to match
438
+ try {
439
+ let noteContent = readFileSync(newPath, 'utf-8');
440
+ noteContent = noteContent.replace(
441
+ /^(# Session \d+:)\s*.*$/m,
442
+ `$1 ${title}`
443
+ );
444
+ writeFileSync(newPath, noteContent);
445
+ console.error(`Updated note H1 to match rename`);
446
+ } catch { /* ignore */ }
447
+ notePath = newPath;
448
+ }
329
449
  }
330
450
 
331
451
  console.error(`Rich checkpoint saved: ${basename(notePath)}`);
@@ -333,13 +453,17 @@ async function main() {
333
453
  console.error(`Could not save checkpoint: ${noteError}`);
334
454
  }
335
455
 
456
+ // Save cumulative state for next compaction
457
+ saveCumulativeState(notesInfo.path, merged, notePath);
458
+
336
459
  // -----------------------------------------------------------------
337
- // Update TODO.md with checkpoint (like "pause session")
460
+ // Update TODO.md with proper ## Continue section (like "pause session")
338
461
  // -----------------------------------------------------------------
339
- if (hookInput.cwd && state) {
462
+ if (hookInput.cwd && notePath) {
340
463
  try {
341
- addTodoCheckpoint(hookInput.cwd, `Pre-compact checkpoint (~${tokenDisplay} tokens):\n${state}`);
342
- console.error('TODO.md checkpoint added');
464
+ const noteFilename = basename(notePath);
465
+ updateTodoContinue(hookInput.cwd, noteFilename, state, tokenDisplay);
466
+ console.error('TODO.md ## Continue section updated');
343
467
  } catch (todoError) {
344
468
  console.error(`Could not update TODO.md: ${todoError}`);
345
469
  }
@@ -352,13 +476,22 @@ async function main() {
352
476
  // Instead, we write the injection payload to a temp file keyed by
353
477
  // session_id. The SessionStart(compact) hook reads it and outputs
354
478
  // to stdout, which IS injected into the post-compaction context.
479
+ //
480
+ // Always fires (even with thin state) — includes note path so the AI
481
+ // can enrich the session note post-compaction using its own context.
355
482
  // -----------------------------------------------------------------------
356
- if (state && hookInput.session_id) {
483
+ if (hookInput.session_id) {
484
+ const stateText = state || `Working directory: ${hookInput.cwd || 'unknown'}`;
485
+ const noteInfo = notePath
486
+ ? `\nSESSION NOTE: ${notePath}\nIf this note still has a generic title (e.g. "New Session", "Context Compression"),\nrename it based on actual work done and add a rich summary.`
487
+ : '';
488
+
357
489
  const injection = [
358
490
  '<system-reminder>',
359
491
  `SESSION STATE RECOVERED AFTER COMPACTION (${compactType}, ~${tokenDisplay} tokens)`,
360
492
  '',
361
- state,
493
+ stateText,
494
+ noteInfo,
362
495
  '',
363
496
  'IMPORTANT: This session state was captured before context compaction.',
364
497
  'Use it to maintain continuity. Continue the conversation from where',