@tekmidian/pai 0.5.4 → 0.5.5

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,14 +2,13 @@
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
+ * This hook now mirrors "pause session" behavior: the session note gets a
11
+ * descriptive name, rich content, and TODO.md gets a continuation prompt.
13
12
  */
14
13
 
15
14
  import { readFileSync, writeFileSync } from 'fs';
@@ -22,8 +21,8 @@ import {
22
21
  appendCheckpoint,
23
22
  addWorkToSessionNote,
24
23
  findNotesDir,
25
- findTodoPath,
26
- addTodoCheckpoint,
24
+ renameSessionNote,
25
+ updateTodoContinue,
27
26
  calculateSessionTokens,
28
27
  WorkItem,
29
28
  } from '../lib/project-utils';
@@ -37,6 +36,16 @@ interface HookInput {
37
36
  trigger?: string;
38
37
  }
39
38
 
39
+ /** Structured data extracted from a transcript in a single pass. */
40
+ interface TranscriptData {
41
+ userMessages: string[];
42
+ summaries: string[];
43
+ captures: string[];
44
+ lastCompleted: string;
45
+ filesModified: string[];
46
+ workItems: WorkItem[];
47
+ }
48
+
40
49
  // ---------------------------------------------------------------------------
41
50
  // Helpers
42
51
  // ---------------------------------------------------------------------------
@@ -80,24 +89,23 @@ function getTranscriptStats(transcriptPath: string): { messageCount: number; isL
80
89
  }
81
90
 
82
91
  // ---------------------------------------------------------------------------
83
- // Session state extraction
92
+ // Unified transcript parser — single pass extracts everything
84
93
  // ---------------------------------------------------------------------------
85
94
 
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 {
95
+ function parseTranscript(transcriptPath: string): TranscriptData {
96
+ const data: TranscriptData = {
97
+ userMessages: [],
98
+ summaries: [],
99
+ captures: [],
100
+ lastCompleted: '',
101
+ filesModified: [],
102
+ workItems: [],
103
+ };
104
+
92
105
  try {
93
106
  const raw = readFileSync(transcriptPath, 'utf-8');
94
107
  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>();
108
+ const seenSummaries = new Set<string>();
101
109
 
102
110
  for (const line of lines) {
103
111
  if (!line.trim()) continue;
@@ -107,151 +115,165 @@ function extractSessionState(transcriptPath: string, cwd?: string): string | nul
107
115
  // --- User messages ---
108
116
  if (entry.type === 'user' && entry.message?.content) {
109
117
  const text = contentToText(entry.message.content).slice(0, 300);
110
- if (text) userMessages.push(text);
118
+ if (text) data.userMessages.push(text);
111
119
  }
112
120
 
113
- // --- Assistant structured sections ---
121
+ // --- Assistant content ---
114
122
  if (entry.type === 'assistant' && entry.message?.content) {
115
123
  const text = contentToText(entry.message.content);
116
124
 
125
+ // Summaries → also create work items
117
126
  const summaryMatch = text.match(/SUMMARY:\s*(.+?)(?:\n|$)/i);
118
127
  if (summaryMatch) {
119
128
  const s = summaryMatch[1].trim();
120
- if (s.length > 5 && !summaries.includes(s)) summaries.push(s);
129
+ if (s.length > 5 && !data.summaries.includes(s)) {
130
+ data.summaries.push(s);
131
+ if (!seenSummaries.has(s)) {
132
+ seenSummaries.add(s);
133
+ const details: string[] = [];
134
+ const actionsMatch = text.match(/ACTIONS:\s*(.+?)(?=\n[A-Z]+:|$)/is);
135
+ if (actionsMatch) {
136
+ const actionLines = actionsMatch[1].split('\n')
137
+ .map(l => l.replace(/^[-*•]\s*/, '').replace(/^\d+\.\s*/, '').trim())
138
+ .filter(l => l.length > 3 && l.length < 100);
139
+ details.push(...actionLines.slice(0, 3));
140
+ }
141
+ data.workItems.push({ title: s, details: details.length > 0 ? details : undefined, completed: true });
142
+ }
143
+ }
121
144
  }
122
145
 
146
+ // Captures
123
147
  const captureMatch = text.match(/CAPTURE:\s*(.+?)(?:\n|$)/i);
124
148
  if (captureMatch) {
125
149
  const c = captureMatch[1].trim();
126
- if (c.length > 5 && !captures.includes(c)) captures.push(c);
150
+ if (c.length > 5 && !data.captures.includes(c)) data.captures.push(c);
127
151
  }
128
152
 
153
+ // Completed
129
154
  const completedMatch = text.match(/COMPLETED:\s*(.+?)(?:\n|$)/i);
130
155
  if (completedMatch) {
131
- lastCompleted = completedMatch[1].trim().replace(/\*+/g, '');
156
+ data.lastCompleted = completedMatch[1].trim().replace(/\*+/g, '');
157
+ if (data.workItems.length === 0 && !seenSummaries.has(data.lastCompleted) && data.lastCompleted.length > 5) {
158
+ seenSummaries.add(data.lastCompleted);
159
+ data.workItems.push({ title: data.lastCompleted, completed: true });
160
+ }
132
161
  }
133
- }
134
162
 
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);
163
+ // File modifications (from tool_use blocks)
164
+ if (Array.isArray(entry.message.content)) {
165
+ for (const block of entry.message.content) {
166
+ if (block.type === 'tool_use') {
167
+ const tool = block.name;
168
+ if ((tool === 'Edit' || tool === 'Write') && block.input?.file_path) {
169
+ if (!data.filesModified.includes(block.input.file_path)) {
170
+ data.filesModified.push(block.input.file_path);
171
+ }
172
+ }
142
173
  }
143
174
  }
144
175
  }
145
176
  }
146
177
  }
178
+ } catch (err) {
179
+ console.error(`parseTranscript error: ${err}`);
180
+ }
147
181
 
148
- // Build the output — keep it concise
149
- const parts: string[] = [];
182
+ return data;
183
+ }
150
184
 
151
- if (cwd) {
152
- parts.push(`Working directory: ${cwd}`);
153
- }
185
+ // ---------------------------------------------------------------------------
186
+ // Format session state as human-readable string
187
+ // ---------------------------------------------------------------------------
154
188
 
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
- }
189
+ function formatSessionState(data: TranscriptData, cwd?: string): string | null {
190
+ const parts: string[] = [];
165
191
 
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
- }
192
+ if (cwd) parts.push(`Working directory: ${cwd}`);
174
193
 
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
- }
194
+ const recentUser = data.userMessages.slice(-3);
195
+ if (recentUser.length > 0) {
196
+ parts.push('\nRecent user requests:');
197
+ for (const msg of recentUser) {
198
+ parts.push(`- ${msg.split('\n')[0].slice(0, 200)}`);
182
199
  }
200
+ }
183
201
 
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
- }
202
+ const recentSummaries = data.summaries.slice(-3);
203
+ if (recentSummaries.length > 0) {
204
+ parts.push('\nWork summaries:');
205
+ for (const s of recentSummaries) parts.push(`- ${s.slice(0, 150)}`);
206
+ }
192
207
 
193
- if (lastCompleted) {
194
- parts.push(`\nLast completed: ${lastCompleted.slice(0, 150)}`);
195
- }
208
+ const recentCaptures = data.captures.slice(-5);
209
+ if (recentCaptures.length > 0) {
210
+ parts.push('\nCaptured context:');
211
+ for (const c of recentCaptures) parts.push(`- ${c.slice(0, 150)}`);
212
+ }
196
213
 
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;
214
+ const files = data.filesModified.slice(-10);
215
+ if (files.length > 0) {
216
+ parts.push('\nFiles modified this session:');
217
+ for (const f of files) parts.push(`- ${f}`);
202
218
  }
219
+
220
+ if (data.lastCompleted) {
221
+ parts.push(`\nLast completed: ${data.lastCompleted.slice(0, 150)}`);
222
+ }
223
+
224
+ const result = parts.join('\n');
225
+ return result.length > 50 ? result : null;
203
226
  }
204
227
 
205
228
  // ---------------------------------------------------------------------------
206
- // Work item extraction (same pattern as stop-hook.ts)
229
+ // Derive a meaningful title for the session note
207
230
  // ---------------------------------------------------------------------------
208
231
 
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>();
215
-
216
- for (const line of lines) {
217
- if (!line.trim()) continue;
218
- let entry: any;
219
- try { entry = JSON.parse(line); } catch { continue; }
220
-
221
- if (entry.type === 'assistant' && entry.message?.content) {
222
- const text = contentToText(entry.message.content);
223
-
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
- }
232
+ function deriveTitle(data: TranscriptData): string {
233
+ let title = '';
240
234
 
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
- }
235
+ // 1. Last work item title (most descriptive of what was accomplished)
236
+ if (data.workItems.length > 0) {
237
+ title = data.workItems[data.workItems.length - 1].title;
238
+ }
239
+ // 2. Last summary
240
+ else if (data.summaries.length > 0) {
241
+ title = data.summaries[data.summaries.length - 1];
242
+ }
243
+ // 3. Last completed marker
244
+ else if (data.lastCompleted && data.lastCompleted.length > 5) {
245
+ title = data.lastCompleted;
246
+ }
247
+ // 4. Last substantive user message
248
+ else if (data.userMessages.length > 0) {
249
+ for (let i = data.userMessages.length - 1; i >= 0; i--) {
250
+ const msg = data.userMessages[i].split('\n')[0].trim();
251
+ if (msg.length > 10 && msg.length < 80 &&
252
+ !msg.toLowerCase().startsWith('yes') &&
253
+ !msg.toLowerCase().startsWith('ok')) {
254
+ title = msg;
255
+ break;
249
256
  }
250
257
  }
251
- return workItems;
252
- } catch {
253
- return [];
254
258
  }
259
+ // 5. Derive from files modified
260
+ if (!title && data.filesModified.length > 0) {
261
+ const basenames = data.filesModified.slice(-5).map(f => {
262
+ const b = basename(f);
263
+ return b.replace(/\.[^.]+$/, '');
264
+ });
265
+ const unique = [...new Set(basenames)];
266
+ title = unique.length <= 3
267
+ ? `Updated ${unique.join(', ')}`
268
+ : `Modified ${data.filesModified.length} files`;
269
+ }
270
+
271
+ // Clean up for filename use
272
+ return title
273
+ .replace(/[^\w\s-]/g, ' ') // Remove special chars
274
+ .replace(/\s+/g, ' ') // Normalize whitespace
275
+ .trim()
276
+ .substring(0, 60);
255
277
  }
256
278
 
257
279
  // ---------------------------------------------------------------------------
@@ -288,24 +310,28 @@ async function main() {
288
310
  ? `${Math.round(tokenCount / 1000)}k`
289
311
  : String(tokenCount);
290
312
 
313
+ // -----------------------------------------------------------------
314
+ // Single-pass transcript parsing
315
+ // -----------------------------------------------------------------
316
+ const data = parseTranscript(hookInput.transcript_path);
317
+ const state = formatSessionState(data, hookInput.cwd);
318
+
291
319
  // -----------------------------------------------------------------
292
320
  // Persist session state to numbered session note (like "pause session")
293
321
  // -----------------------------------------------------------------
294
- const state = extractSessionState(hookInput.transcript_path, hookInput.cwd);
322
+ let notePath: string | null = null;
295
323
 
296
324
  try {
297
- // Find notes dir — prefer local, fallback to central
298
325
  const notesInfo = hookInput.cwd
299
326
  ? findNotesDir(hookInput.cwd)
300
327
  : { path: join(dirname(hookInput.transcript_path), 'Notes'), isLocal: false };
301
- let notePath = getCurrentNotePath(notesInfo.path);
328
+ notePath = getCurrentNotePath(notesInfo.path);
302
329
 
303
330
  // If no note found, or the latest note is completed, create a new one
304
331
  if (!notePath) {
305
332
  console.error('No session note found — creating one for checkpoint');
306
333
  notePath = createSessionNote(notesInfo.path, 'Recovered Session');
307
334
  } else {
308
- // Check if the found note is already completed — don't write to completed notes
309
335
  try {
310
336
  const noteContent = readFileSync(notePath, 'utf-8');
311
337
  if (noteContent.includes('**Status:** Completed') || noteContent.includes('**Completed:**')) {
@@ -321,11 +347,29 @@ async function main() {
321
347
  : `Context compression triggered at ~${tokenDisplay} tokens with ${stats.messageCount} messages.`;
322
348
  appendCheckpoint(notePath, checkpointBody);
323
349
 
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`);
350
+ // 2. Write work items to "Work Done" section
351
+ if (data.workItems.length > 0) {
352
+ addWorkToSessionNote(notePath, data.workItems, `Pre-Compact (~${tokenDisplay} tokens)`);
353
+ console.error(`Added ${data.workItems.length} work item(s) to session note`);
354
+ }
355
+
356
+ // 3. Rename session note with a meaningful title (instead of "New Session")
357
+ const title = deriveTitle(data);
358
+ if (title) {
359
+ const newPath = renameSessionNote(notePath, title);
360
+ if (newPath !== notePath) {
361
+ // Update H1 title inside the note to match
362
+ try {
363
+ let noteContent = readFileSync(newPath, 'utf-8');
364
+ noteContent = noteContent.replace(
365
+ /^(# Session \d+:)\s*.*$/m,
366
+ `$1 ${title}`
367
+ );
368
+ writeFileSync(newPath, noteContent);
369
+ console.error(`Updated note H1 to match rename`);
370
+ } catch { /* ignore */ }
371
+ notePath = newPath;
372
+ }
329
373
  }
330
374
 
331
375
  console.error(`Rich checkpoint saved: ${basename(notePath)}`);
@@ -334,12 +378,13 @@ async function main() {
334
378
  }
335
379
 
336
380
  // -----------------------------------------------------------------
337
- // Update TODO.md with checkpoint (like "pause session")
381
+ // Update TODO.md with proper ## Continue section (like "pause session")
338
382
  // -----------------------------------------------------------------
339
- if (hookInput.cwd && state) {
383
+ if (hookInput.cwd && notePath) {
340
384
  try {
341
- addTodoCheckpoint(hookInput.cwd, `Pre-compact checkpoint (~${tokenDisplay} tokens):\n${state}`);
342
- console.error('TODO.md checkpoint added');
385
+ const noteFilename = basename(notePath);
386
+ updateTodoContinue(hookInput.cwd, noteFilename, state, tokenDisplay);
387
+ console.error('TODO.md ## Continue section updated');
343
388
  } catch (todoError) {
344
389
  console.error(`Could not update TODO.md: ${todoError}`);
345
390
  }