@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.
- package/dist/hooks/cleanup-session-files.mjs.map +1 -1
- package/dist/hooks/context-compression-hook.mjs +173 -130
- package/dist/hooks/context-compression-hook.mjs.map +3 -3
- package/dist/hooks/initialize-session.mjs.map +1 -1
- package/dist/hooks/load-project-context.mjs.map +1 -1
- package/dist/hooks/stop-hook.mjs.map +1 -1
- package/dist/hooks/sync-todo-to-md.mjs.map +1 -1
- package/package.json +1 -1
- package/src/hooks/ts/lib/project-utils.ts +52 -0
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +182 -137
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* PreCompact Hook - Triggered before context compression
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* 1. Save checkpoint to session note
|
|
7
|
-
* 2.
|
|
8
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
//
|
|
92
|
+
// Unified transcript parser — single pass extracts everything
|
|
84
93
|
// ---------------------------------------------------------------------------
|
|
85
94
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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))
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
149
|
-
|
|
182
|
+
return data;
|
|
183
|
+
}
|
|
150
184
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Format session state as human-readable string
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
154
188
|
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
//
|
|
229
|
+
// Derive a meaningful title for the session note
|
|
207
230
|
// ---------------------------------------------------------------------------
|
|
208
231
|
|
|
209
|
-
function
|
|
210
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
381
|
+
// Update TODO.md with proper ## Continue section (like "pause session")
|
|
338
382
|
// -----------------------------------------------------------------
|
|
339
|
-
if (hookInput.cwd &&
|
|
383
|
+
if (hookInput.cwd && notePath) {
|
|
340
384
|
try {
|
|
341
|
-
|
|
342
|
-
|
|
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
|
}
|