@tekmidian/pai 0.5.2 → 0.5.3

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.
@@ -1,143 +1,366 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * PreCompact Hook - Triggered before context compression
4
- * Extracts context information from transcript and notifies about compression
5
4
  *
6
- * Enhanced to:
7
- * - Save checkpoint to current session note
8
- * - Send ntfy.sh notification
9
- * - Calculate approximate token count
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.
10
+ *
11
+ * Without (2), compaction produces a generic summary and the session loses
12
+ * critical context: current task, recent requests, file paths, decisions.
10
13
  */
11
14
 
12
- import { readFileSync } from 'fs';
13
- import { join, basename, dirname } from 'path';
15
+ import { readFileSync, writeFileSync } from 'fs';
16
+ import { basename, dirname, join } from 'path';
17
+ import { tmpdir } from 'os';
14
18
  import {
15
19
  sendNtfyNotification,
16
20
  getCurrentNotePath,
17
21
  appendCheckpoint,
18
- calculateSessionTokens
22
+ addWorkToSessionNote,
23
+ findNotesDir,
24
+ findTodoPath,
25
+ addTodoCheckpoint,
26
+ calculateSessionTokens,
27
+ WorkItem,
19
28
  } from '../lib/project-utils';
20
29
 
21
30
  interface HookInput {
22
31
  session_id: string;
23
32
  transcript_path: string;
33
+ cwd?: string;
24
34
  hook_event_name: string;
25
35
  compact_type?: string;
36
+ trigger?: string;
26
37
  }
27
38
 
28
- interface TranscriptEntry {
29
- type: string;
30
- message?: {
31
- role?: string;
32
- content?: Array<{
33
- type: string;
34
- text: string;
35
- }>
36
- };
37
- timestamp?: string;
39
+ // ---------------------------------------------------------------------------
40
+ // Helpers
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /** Turn Claude content (string or content block array) into plain text. */
44
+ function contentToText(content: unknown): string {
45
+ if (typeof content === 'string') return content;
46
+ if (Array.isArray(content)) {
47
+ return content
48
+ .map((c) => {
49
+ if (typeof c === 'string') return c;
50
+ if (c?.text) return c.text;
51
+ if (c?.content) return String(c.content);
52
+ return '';
53
+ })
54
+ .join(' ')
55
+ .trim();
56
+ }
57
+ return '';
38
58
  }
39
59
 
40
- /**
41
- * Count messages in transcript to provide context
42
- */
43
60
  function getTranscriptStats(transcriptPath: string): { messageCount: number; isLarge: boolean } {
44
61
  try {
45
62
  const content = readFileSync(transcriptPath, 'utf-8');
46
63
  const lines = content.trim().split('\n');
47
-
48
64
  let userMessages = 0;
49
65
  let assistantMessages = 0;
66
+ for (const line of lines) {
67
+ if (!line.trim()) continue;
68
+ try {
69
+ const entry = JSON.parse(line);
70
+ if (entry.type === 'user') userMessages++;
71
+ else if (entry.type === 'assistant') assistantMessages++;
72
+ } catch { /* skip */ }
73
+ }
74
+ const totalMessages = userMessages + assistantMessages;
75
+ return { messageCount: totalMessages, isLarge: totalMessages > 50 };
76
+ } catch {
77
+ return { messageCount: 0, isLarge: false };
78
+ }
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Session state extraction
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * Extract structured session state from the transcript JSONL.
87
+ * Returns a human-readable summary (<2000 chars) suitable for injection
88
+ * into the conversation before compaction.
89
+ */
90
+ function extractSessionState(transcriptPath: string, cwd?: string): string | null {
91
+ try {
92
+ const raw = readFileSync(transcriptPath, 'utf-8');
93
+ const lines = raw.trim().split('\n');
94
+
95
+ const userMessages: string[] = [];
96
+ const summaries: string[] = [];
97
+ const captures: string[] = [];
98
+ let lastCompleted = '';
99
+ const filesModified = new Set<string>();
50
100
 
51
101
  for (const line of lines) {
52
- if (line.trim()) {
53
- try {
54
- const entry = JSON.parse(line) as TranscriptEntry;
55
- if (entry.type === 'user') {
56
- userMessages++;
57
- } else if (entry.type === 'assistant') {
58
- assistantMessages++;
102
+ if (!line.trim()) continue;
103
+ let entry: any;
104
+ try { entry = JSON.parse(line); } catch { continue; }
105
+
106
+ // --- User messages ---
107
+ if (entry.type === 'user' && entry.message?.content) {
108
+ const text = contentToText(entry.message.content).slice(0, 300);
109
+ if (text) userMessages.push(text);
110
+ }
111
+
112
+ // --- Assistant structured sections ---
113
+ if (entry.type === 'assistant' && entry.message?.content) {
114
+ const text = contentToText(entry.message.content);
115
+
116
+ const summaryMatch = text.match(/SUMMARY:\s*(.+?)(?:\n|$)/i);
117
+ if (summaryMatch) {
118
+ const s = summaryMatch[1].trim();
119
+ if (s.length > 5 && !summaries.includes(s)) summaries.push(s);
120
+ }
121
+
122
+ const captureMatch = text.match(/CAPTURE:\s*(.+?)(?:\n|$)/i);
123
+ if (captureMatch) {
124
+ const c = captureMatch[1].trim();
125
+ if (c.length > 5 && !captures.includes(c)) captures.push(c);
126
+ }
127
+
128
+ const completedMatch = text.match(/COMPLETED:\s*(.+?)(?:\n|$)/i);
129
+ if (completedMatch) {
130
+ lastCompleted = completedMatch[1].trim().replace(/\*+/g, '');
131
+ }
132
+ }
133
+
134
+ // --- Tool use: file modifications ---
135
+ if (entry.type === 'assistant' && entry.message?.content && Array.isArray(entry.message.content)) {
136
+ for (const block of entry.message.content) {
137
+ if (block.type === 'tool_use') {
138
+ const tool = block.name;
139
+ if ((tool === 'Edit' || tool === 'Write') && block.input?.file_path) {
140
+ filesModified.add(block.input.file_path);
141
+ }
59
142
  }
60
- } catch {
61
- // Skip invalid JSON lines
62
143
  }
63
144
  }
64
145
  }
65
146
 
66
- const totalMessages = userMessages + assistantMessages;
67
- const isLarge = totalMessages > 50; // Consider large if more than 50 messages
147
+ // Build the output keep it concise
148
+ const parts: string[] = [];
68
149
 
69
- return { messageCount: totalMessages, isLarge };
70
- } catch (error) {
71
- return { messageCount: 0, isLarge: false };
150
+ if (cwd) {
151
+ parts.push(`Working directory: ${cwd}`);
152
+ }
153
+
154
+ // Last 3 user messages
155
+ const recentUser = userMessages.slice(-3);
156
+ if (recentUser.length > 0) {
157
+ parts.push('\nRecent user requests:');
158
+ for (const msg of recentUser) {
159
+ // Trim to first line or 200 chars
160
+ const firstLine = msg.split('\n')[0].slice(0, 200);
161
+ parts.push(`- ${firstLine}`);
162
+ }
163
+ }
164
+
165
+ // Summaries (last 3)
166
+ const recentSummaries = summaries.slice(-3);
167
+ if (recentSummaries.length > 0) {
168
+ parts.push('\nWork summaries:');
169
+ for (const s of recentSummaries) {
170
+ parts.push(`- ${s.slice(0, 150)}`);
171
+ }
172
+ }
173
+
174
+ // Captures (last 5)
175
+ const recentCaptures = captures.slice(-5);
176
+ if (recentCaptures.length > 0) {
177
+ parts.push('\nCaptured context:');
178
+ for (const c of recentCaptures) {
179
+ parts.push(`- ${c.slice(0, 150)}`);
180
+ }
181
+ }
182
+
183
+ // Files modified (last 10)
184
+ const files = Array.from(filesModified).slice(-10);
185
+ if (files.length > 0) {
186
+ parts.push('\nFiles modified this session:');
187
+ for (const f of files) {
188
+ parts.push(`- ${f}`);
189
+ }
190
+ }
191
+
192
+ if (lastCompleted) {
193
+ parts.push(`\nLast completed: ${lastCompleted.slice(0, 150)}`);
194
+ }
195
+
196
+ const result = parts.join('\n');
197
+ return result.length > 50 ? result : null;
198
+ } catch (err) {
199
+ console.error(`extractSessionState error: ${err}`);
200
+ return null;
201
+ }
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Work item extraction (same pattern as stop-hook.ts)
206
+ // ---------------------------------------------------------------------------
207
+
208
+ function extractWorkFromTranscript(transcriptPath: string): WorkItem[] {
209
+ try {
210
+ const raw = readFileSync(transcriptPath, 'utf-8');
211
+ const lines = raw.trim().split('\n');
212
+ const workItems: WorkItem[] = [];
213
+ const seenSummaries = new Set<string>();
214
+
215
+ for (const line of lines) {
216
+ if (!line.trim()) continue;
217
+ let entry: any;
218
+ try { entry = JSON.parse(line); } catch { continue; }
219
+
220
+ if (entry.type === 'assistant' && entry.message?.content) {
221
+ const text = contentToText(entry.message.content);
222
+
223
+ const summaryMatch = text.match(/SUMMARY:\s*(.+?)(?:\n|$)/i);
224
+ if (summaryMatch) {
225
+ const summary = summaryMatch[1].trim();
226
+ if (summary && !seenSummaries.has(summary) && summary.length > 5) {
227
+ seenSummaries.add(summary);
228
+ const details: string[] = [];
229
+ const actionsMatch = text.match(/ACTIONS:\s*(.+?)(?=\n[A-Z]+:|$)/is);
230
+ if (actionsMatch) {
231
+ const actionLines = actionsMatch[1].split('\n')
232
+ .map(l => l.replace(/^[-*•]\s*/, '').replace(/^\d+\.\s*/, '').trim())
233
+ .filter(l => l.length > 3 && l.length < 100);
234
+ details.push(...actionLines.slice(0, 3));
235
+ }
236
+ workItems.push({ title: summary, details: details.length > 0 ? details : undefined, completed: true });
237
+ }
238
+ }
239
+
240
+ const completedMatch = text.match(/COMPLETED:\s*(.+?)(?:\n|$)/i);
241
+ if (completedMatch && workItems.length === 0) {
242
+ const completed = completedMatch[1].trim().replace(/\*+/g, '');
243
+ if (completed && !seenSummaries.has(completed) && completed.length > 5) {
244
+ seenSummaries.add(completed);
245
+ workItems.push({ title: completed, completed: true });
246
+ }
247
+ }
248
+ }
249
+ }
250
+ return workItems;
251
+ } catch {
252
+ return [];
72
253
  }
73
254
  }
74
255
 
256
+ // ---------------------------------------------------------------------------
257
+ // Main
258
+ // ---------------------------------------------------------------------------
259
+
75
260
  async function main() {
76
261
  let hookInput: HookInput | null = null;
77
262
 
78
263
  try {
79
- // Read the JSON input from stdin
80
264
  const decoder = new TextDecoder();
81
265
  let input = '';
82
-
83
- const timeoutPromise = new Promise<void>((resolve) => {
84
- setTimeout(() => resolve(), 500);
85
- });
86
-
266
+ const timeoutPromise = new Promise<void>((resolve) => { setTimeout(resolve, 500); });
87
267
  const readPromise = (async () => {
88
268
  for await (const chunk of process.stdin) {
89
269
  input += decoder.decode(chunk, { stream: true });
90
270
  }
91
271
  })();
92
-
93
272
  await Promise.race([readPromise, timeoutPromise]);
94
-
95
273
  if (input.trim()) {
96
274
  hookInput = JSON.parse(input) as HookInput;
97
275
  }
98
- } catch (error) {
276
+ } catch {
99
277
  // Silently handle input errors
100
278
  }
101
279
 
102
- // Determine the type of compression
103
- const compactType = hookInput?.compact_type || 'auto';
104
- let message = 'Compressing context to continue';
105
-
106
- // Get transcript statistics if available
280
+ const compactType = hookInput?.compact_type || hookInput?.trigger || 'auto';
107
281
  let tokenCount = 0;
108
- if (hookInput && hookInput.transcript_path) {
109
- const stats = getTranscriptStats(hookInput.transcript_path);
110
282
 
111
- // Calculate approximate token count
283
+ if (hookInput?.transcript_path) {
284
+ const stats = getTranscriptStats(hookInput.transcript_path);
112
285
  tokenCount = calculateSessionTokens(hookInput.transcript_path);
113
286
  const tokenDisplay = tokenCount > 1000
114
287
  ? `${Math.round(tokenCount / 1000)}k`
115
288
  : String(tokenCount);
116
289
 
117
- if (stats.messageCount > 0) {
118
- if (compactType === 'manual') {
119
- message = `Manually compressing ${stats.messageCount} messages (~${tokenDisplay} tokens)`;
120
- } else {
121
- message = stats.isLarge
122
- ? `Auto-compressing large context (~${tokenDisplay} tokens)`
123
- : `Compressing context (~${tokenDisplay} tokens)`;
124
- }
125
- }
290
+ // -----------------------------------------------------------------
291
+ // Persist session state to numbered session note (like "pause session")
292
+ // -----------------------------------------------------------------
293
+ const state = extractSessionState(hookInput.transcript_path, hookInput.cwd);
126
294
 
127
- // Save checkpoint to session note before compression
128
295
  try {
129
- const transcriptDir = dirname(hookInput.transcript_path);
130
- const notesDir = join(transcriptDir, 'Notes');
131
- const currentNotePath = getCurrentNotePath(notesDir);
296
+ // Find notes dir — prefer local, fallback to central
297
+ const notesInfo = hookInput.cwd
298
+ ? findNotesDir(hookInput.cwd)
299
+ : { path: join(dirname(hookInput.transcript_path), 'Notes'), isLocal: false };
300
+ const currentNotePath = getCurrentNotePath(notesInfo.path);
132
301
 
133
302
  if (currentNotePath) {
134
- const checkpoint = `Context compression triggered at ~${tokenDisplay} tokens with ${stats.messageCount} messages.`;
135
- appendCheckpoint(currentNotePath, checkpoint);
136
- console.error(`Checkpoint saved before compression: ${basename(currentNotePath)}`);
303
+ // 1. Write rich checkpoint with full session state
304
+ const checkpointBody = state
305
+ ? `Context compression triggered at ~${tokenDisplay} tokens with ${stats.messageCount} messages.\n\n${state}`
306
+ : `Context compression triggered at ~${tokenDisplay} tokens with ${stats.messageCount} messages.`;
307
+ appendCheckpoint(currentNotePath, checkpointBody);
308
+
309
+ // 2. Write work items to "Work Done" section (same as stop-hook)
310
+ const workItems = extractWorkFromTranscript(hookInput.transcript_path);
311
+ if (workItems.length > 0) {
312
+ addWorkToSessionNote(currentNotePath, workItems, `Pre-Compact (~${tokenDisplay} tokens)`);
313
+ console.error(`Added ${workItems.length} work item(s) to session note`);
314
+ }
315
+
316
+ console.error(`Rich checkpoint saved: ${basename(currentNotePath)}`);
137
317
  }
138
318
  } catch (noteError) {
139
319
  console.error(`Could not save checkpoint: ${noteError}`);
140
320
  }
321
+
322
+ // -----------------------------------------------------------------
323
+ // Update TODO.md with checkpoint (like "pause session")
324
+ // -----------------------------------------------------------------
325
+ if (hookInput.cwd && state) {
326
+ try {
327
+ addTodoCheckpoint(hookInput.cwd, `Pre-compact checkpoint (~${tokenDisplay} tokens):\n${state}`);
328
+ console.error('TODO.md checkpoint added');
329
+ } catch (todoError) {
330
+ console.error(`Could not update TODO.md: ${todoError}`);
331
+ }
332
+ }
333
+
334
+ // -----------------------------------------------------------------------
335
+ // Save session state to temp file for post-compact injection.
336
+ //
337
+ // PreCompact hooks have NO stdout support (Claude Code ignores it).
338
+ // Instead, we write the injection payload to a temp file keyed by
339
+ // session_id. The SessionStart(compact) hook reads it and outputs
340
+ // to stdout, which IS injected into the post-compaction context.
341
+ // -----------------------------------------------------------------------
342
+ if (state && hookInput.session_id) {
343
+ const injection = [
344
+ '<system-reminder>',
345
+ `SESSION STATE RECOVERED AFTER COMPACTION (${compactType}, ~${tokenDisplay} tokens)`,
346
+ '',
347
+ state,
348
+ '',
349
+ 'IMPORTANT: This session state was captured before context compaction.',
350
+ 'Use it to maintain continuity. Continue the conversation from where',
351
+ 'it left off without asking the user to repeat themselves.',
352
+ 'Continue with the last task that you were asked to work on.',
353
+ '</system-reminder>',
354
+ ].join('\n');
355
+
356
+ try {
357
+ const stateFile = join(tmpdir(), `pai-compact-state-${hookInput.session_id}.txt`);
358
+ writeFileSync(stateFile, injection, 'utf-8');
359
+ console.error(`Session state saved to ${stateFile} (${injection.length} chars)`);
360
+ } catch (err) {
361
+ console.error(`Failed to save state file: ${err}`);
362
+ }
363
+ }
141
364
  }
142
365
 
143
366
  // Send ntfy.sh notification
@@ -149,7 +372,6 @@ async function main() {
149
372
  process.exit(0);
150
373
  }
151
374
 
152
- // Run the hook
153
375
  main().catch(() => {
154
376
  process.exit(0);
155
377
  });
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * post-compact-inject.ts — SessionStart hook (matcher: "compact")
5
+ *
6
+ * Fires AFTER auto/manual compaction. Reads the session state that
7
+ * the PreCompact hook saved to a temp file and outputs it to stdout,
8
+ * which Claude Code injects into the post-compaction context.
9
+ *
10
+ * This is the ONLY way to influence what Claude knows after compaction:
11
+ * PreCompact hooks have no stdout support, but SessionStart does.
12
+ *
13
+ * Flow:
14
+ * PreCompact → context-compression-hook.ts saves state to /tmp/pai-compact-state-{sessionId}.txt
15
+ * Compaction runs (conversation is summarized)
16
+ * SessionStart(compact) → THIS HOOK reads that file → stdout → context
17
+ */
18
+
19
+ import { readFileSync, existsSync, unlinkSync } from 'fs';
20
+ import { join } from 'path';
21
+ import { tmpdir } from 'os';
22
+
23
+ interface HookInput {
24
+ session_id: string;
25
+ transcript_path?: string;
26
+ cwd?: string;
27
+ hook_event_name: string;
28
+ source?: string;
29
+ }
30
+
31
+ async function main() {
32
+ let hookInput: HookInput | null = null;
33
+
34
+ try {
35
+ const decoder = new TextDecoder();
36
+ let input = '';
37
+ const timeoutPromise = new Promise<void>((resolve) => { setTimeout(resolve, 500); });
38
+ const readPromise = (async () => {
39
+ for await (const chunk of process.stdin) {
40
+ input += decoder.decode(chunk, { stream: true });
41
+ }
42
+ })();
43
+ await Promise.race([readPromise, timeoutPromise]);
44
+ if (input.trim()) {
45
+ hookInput = JSON.parse(input) as HookInput;
46
+ }
47
+ } catch {
48
+ // Silently handle input errors
49
+ }
50
+
51
+ if (!hookInput?.session_id) {
52
+ console.error('post-compact-inject: no session_id, exiting');
53
+ process.exit(0);
54
+ }
55
+
56
+ // Look for the state file saved by context-compression-hook during PreCompact
57
+ const stateFile = join(tmpdir(), `pai-compact-state-${hookInput.session_id}.txt`);
58
+
59
+ if (!existsSync(stateFile)) {
60
+ console.error(`post-compact-inject: no state file found at ${stateFile}`);
61
+ process.exit(0);
62
+ }
63
+
64
+ try {
65
+ const state = readFileSync(stateFile, 'utf-8').trim();
66
+
67
+ if (state.length > 0) {
68
+ // Output to stdout — Claude Code injects this into the post-compaction context
69
+ console.log(state);
70
+ console.error(`post-compact-inject: injected ${state.length} chars of session state`);
71
+ }
72
+
73
+ // Clean up the temp file
74
+ unlinkSync(stateFile);
75
+ console.error(`post-compact-inject: cleaned up ${stateFile}`);
76
+ } catch (err) {
77
+ console.error(`post-compact-inject: error reading state file: ${err}`);
78
+ }
79
+
80
+ process.exit(0);
81
+ }
82
+
83
+ main().catch(() => {
84
+ process.exit(0);
85
+ });