@tekmidian/pai 0.5.2 → 0.5.4

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.
@@ -437,8 +437,24 @@ export function createSessionNote(notesDir: string, description: string): string
437
437
  */
438
438
  export function appendCheckpoint(notePath: string, checkpoint: string): void {
439
439
  if (!existsSync(notePath)) {
440
- console.error(`Note file not found: ${notePath}`);
441
- return;
440
+ // Note vanished (cloud sync, cleanup, etc.) — recreate it
441
+ console.error(`Note file not found, recreating: ${notePath}`);
442
+ try {
443
+ const parentDir = join(notePath, '..');
444
+ if (!existsSync(parentDir)) {
445
+ mkdirSync(parentDir, { recursive: true });
446
+ }
447
+ const noteFilename = basename(notePath);
448
+ const numberMatch = noteFilename.match(/^(\d+)/);
449
+ const noteNumber = numberMatch ? numberMatch[1] : '0000';
450
+ const date = new Date().toISOString().split('T')[0];
451
+ const content = `# Session ${noteNumber}: Recovered\n\n**Date:** ${date}\n**Status:** In Progress\n\n---\n\n## Work Done\n\n<!-- PAI will add completed work here during session -->\n\n---\n\n## Next Steps\n\n<!-- To be filled at session end -->\n\n---\n\n**Tags:** #Session\n`;
452
+ writeFileSync(notePath, content);
453
+ console.error(`Recreated session note: ${noteFilename}`);
454
+ } catch (err) {
455
+ console.error(`Failed to recreate note: ${err}`);
456
+ return;
457
+ }
442
458
  }
443
459
 
444
460
  const content = readFileSync(notePath, 'utf-8');
@@ -891,6 +907,7 @@ ${sessionSummary ? `**Session Summary:** ${sessionSummary}\n\n` : ''}${backlogSe
891
907
  /**
892
908
  * Add a checkpoint entry to TODO.md (without replacing tasks)
893
909
  * Ensures only ONE timestamp line at the end
910
+ * Works regardless of TODO.md structure — appends if no known section found
894
911
  */
895
912
  export function addTodoCheckpoint(cwd: string, checkpoint: string): void {
896
913
  const todoPath = ensureTodoMd(cwd);
@@ -899,11 +916,28 @@ export function addTodoCheckpoint(cwd: string, checkpoint: string): void {
899
916
  // Remove ALL existing timestamp lines and trailing separators
900
917
  content = content.replace(/(\n---\s*)*(\n\*Last updated:.*\*\s*)+$/g, '');
901
918
 
902
- // Add checkpoint before Backlog section
919
+ const checkpointText = `\n**Checkpoint (${new Date().toISOString()}):** ${checkpoint}\n\n`;
920
+
921
+ // Try to insert before Backlog section
903
922
  const backlogIndex = content.indexOf('## Backlog');
904
923
  if (backlogIndex !== -1) {
905
- const checkpointText = `\n**Checkpoint (${new Date().toISOString()}):** ${checkpoint}\n\n`;
906
924
  content = content.substring(0, backlogIndex) + checkpointText + content.substring(backlogIndex);
925
+ } else {
926
+ // No Backlog section — try before Continue section, or just append
927
+ const continueIndex = content.indexOf('## Continue');
928
+ if (continueIndex !== -1) {
929
+ // Insert after the Continue section (find the next ## or ---)
930
+ const afterContinue = content.indexOf('\n---', continueIndex);
931
+ if (afterContinue !== -1) {
932
+ const insertAt = afterContinue + 4; // after \n---
933
+ content = content.substring(0, insertAt) + '\n' + checkpointText + content.substring(insertAt);
934
+ } else {
935
+ content = content.trimEnd() + '\n' + checkpointText;
936
+ }
937
+ } else {
938
+ // No known section — just append before the end
939
+ content = content.trimEnd() + '\n' + checkpointText;
940
+ }
907
941
  }
908
942
 
909
943
  // Add exactly ONE timestamp at the end
@@ -1,143 +1,380 @@
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,
21
+ createSessionNote,
17
22
  appendCheckpoint,
18
- calculateSessionTokens
23
+ addWorkToSessionNote,
24
+ findNotesDir,
25
+ findTodoPath,
26
+ addTodoCheckpoint,
27
+ calculateSessionTokens,
28
+ WorkItem,
19
29
  } from '../lib/project-utils';
20
30
 
21
31
  interface HookInput {
22
32
  session_id: string;
23
33
  transcript_path: string;
34
+ cwd?: string;
24
35
  hook_event_name: string;
25
36
  compact_type?: string;
37
+ trigger?: string;
26
38
  }
27
39
 
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;
40
+ // ---------------------------------------------------------------------------
41
+ // Helpers
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** Turn Claude content (string or content block array) into plain text. */
45
+ function contentToText(content: unknown): string {
46
+ if (typeof content === 'string') return content;
47
+ if (Array.isArray(content)) {
48
+ return content
49
+ .map((c) => {
50
+ if (typeof c === 'string') return c;
51
+ if (c?.text) return c.text;
52
+ if (c?.content) return String(c.content);
53
+ return '';
54
+ })
55
+ .join(' ')
56
+ .trim();
57
+ }
58
+ return '';
38
59
  }
39
60
 
40
- /**
41
- * Count messages in transcript to provide context
42
- */
43
61
  function getTranscriptStats(transcriptPath: string): { messageCount: number; isLarge: boolean } {
44
62
  try {
45
63
  const content = readFileSync(transcriptPath, 'utf-8');
46
64
  const lines = content.trim().split('\n');
47
-
48
65
  let userMessages = 0;
49
66
  let assistantMessages = 0;
67
+ for (const line of lines) {
68
+ if (!line.trim()) continue;
69
+ try {
70
+ const entry = JSON.parse(line);
71
+ if (entry.type === 'user') userMessages++;
72
+ else if (entry.type === 'assistant') assistantMessages++;
73
+ } catch { /* skip */ }
74
+ }
75
+ const totalMessages = userMessages + assistantMessages;
76
+ return { messageCount: totalMessages, isLarge: totalMessages > 50 };
77
+ } catch {
78
+ return { messageCount: 0, isLarge: false };
79
+ }
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Session state extraction
84
+ // ---------------------------------------------------------------------------
85
+
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 {
92
+ try {
93
+ const raw = readFileSync(transcriptPath, 'utf-8');
94
+ 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>();
50
101
 
51
102
  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++;
103
+ if (!line.trim()) continue;
104
+ let entry: any;
105
+ try { entry = JSON.parse(line); } catch { continue; }
106
+
107
+ // --- User messages ---
108
+ if (entry.type === 'user' && entry.message?.content) {
109
+ const text = contentToText(entry.message.content).slice(0, 300);
110
+ if (text) userMessages.push(text);
111
+ }
112
+
113
+ // --- Assistant structured sections ---
114
+ if (entry.type === 'assistant' && entry.message?.content) {
115
+ const text = contentToText(entry.message.content);
116
+
117
+ const summaryMatch = text.match(/SUMMARY:\s*(.+?)(?:\n|$)/i);
118
+ if (summaryMatch) {
119
+ const s = summaryMatch[1].trim();
120
+ if (s.length > 5 && !summaries.includes(s)) summaries.push(s);
121
+ }
122
+
123
+ const captureMatch = text.match(/CAPTURE:\s*(.+?)(?:\n|$)/i);
124
+ if (captureMatch) {
125
+ const c = captureMatch[1].trim();
126
+ if (c.length > 5 && !captures.includes(c)) captures.push(c);
127
+ }
128
+
129
+ const completedMatch = text.match(/COMPLETED:\s*(.+?)(?:\n|$)/i);
130
+ if (completedMatch) {
131
+ lastCompleted = completedMatch[1].trim().replace(/\*+/g, '');
132
+ }
133
+ }
134
+
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);
142
+ }
59
143
  }
60
- } catch {
61
- // Skip invalid JSON lines
62
144
  }
63
145
  }
64
146
  }
65
147
 
66
- const totalMessages = userMessages + assistantMessages;
67
- const isLarge = totalMessages > 50; // Consider large if more than 50 messages
148
+ // Build the output keep it concise
149
+ const parts: string[] = [];
68
150
 
69
- return { messageCount: totalMessages, isLarge };
70
- } catch (error) {
71
- return { messageCount: 0, isLarge: false };
151
+ if (cwd) {
152
+ parts.push(`Working directory: ${cwd}`);
153
+ }
154
+
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
+ }
165
+
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
+ }
174
+
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
+ }
182
+ }
183
+
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
+ }
192
+
193
+ if (lastCompleted) {
194
+ parts.push(`\nLast completed: ${lastCompleted.slice(0, 150)}`);
195
+ }
196
+
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;
202
+ }
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Work item extraction (same pattern as stop-hook.ts)
207
+ // ---------------------------------------------------------------------------
208
+
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
+ }
240
+
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;
252
+ } catch {
253
+ return [];
72
254
  }
73
255
  }
74
256
 
257
+ // ---------------------------------------------------------------------------
258
+ // Main
259
+ // ---------------------------------------------------------------------------
260
+
75
261
  async function main() {
76
262
  let hookInput: HookInput | null = null;
77
263
 
78
264
  try {
79
- // Read the JSON input from stdin
80
265
  const decoder = new TextDecoder();
81
266
  let input = '';
82
-
83
- const timeoutPromise = new Promise<void>((resolve) => {
84
- setTimeout(() => resolve(), 500);
85
- });
86
-
267
+ const timeoutPromise = new Promise<void>((resolve) => { setTimeout(resolve, 500); });
87
268
  const readPromise = (async () => {
88
269
  for await (const chunk of process.stdin) {
89
270
  input += decoder.decode(chunk, { stream: true });
90
271
  }
91
272
  })();
92
-
93
273
  await Promise.race([readPromise, timeoutPromise]);
94
-
95
274
  if (input.trim()) {
96
275
  hookInput = JSON.parse(input) as HookInput;
97
276
  }
98
- } catch (error) {
277
+ } catch {
99
278
  // Silently handle input errors
100
279
  }
101
280
 
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
281
+ const compactType = hookInput?.compact_type || hookInput?.trigger || 'auto';
107
282
  let tokenCount = 0;
108
- if (hookInput && hookInput.transcript_path) {
109
- const stats = getTranscriptStats(hookInput.transcript_path);
110
283
 
111
- // Calculate approximate token count
284
+ if (hookInput?.transcript_path) {
285
+ const stats = getTranscriptStats(hookInput.transcript_path);
112
286
  tokenCount = calculateSessionTokens(hookInput.transcript_path);
113
287
  const tokenDisplay = tokenCount > 1000
114
288
  ? `${Math.round(tokenCount / 1000)}k`
115
289
  : String(tokenCount);
116
290
 
117
- if (stats.messageCount > 0) {
118
- if (compactType === 'manual') {
119
- message = `Manually compressing ${stats.messageCount} messages (~${tokenDisplay} tokens)`;
291
+ // -----------------------------------------------------------------
292
+ // Persist session state to numbered session note (like "pause session")
293
+ // -----------------------------------------------------------------
294
+ const state = extractSessionState(hookInput.transcript_path, hookInput.cwd);
295
+
296
+ try {
297
+ // Find notes dir — prefer local, fallback to central
298
+ const notesInfo = hookInput.cwd
299
+ ? findNotesDir(hookInput.cwd)
300
+ : { path: join(dirname(hookInput.transcript_path), 'Notes'), isLocal: false };
301
+ let notePath = getCurrentNotePath(notesInfo.path);
302
+
303
+ // If no note found, or the latest note is completed, create a new one
304
+ if (!notePath) {
305
+ console.error('No session note found — creating one for checkpoint');
306
+ notePath = createSessionNote(notesInfo.path, 'Recovered Session');
120
307
  } else {
121
- message = stats.isLarge
122
- ? `Auto-compressing large context (~${tokenDisplay} tokens)`
123
- : `Compressing context (~${tokenDisplay} tokens)`;
308
+ // Check if the found note is already completed — don't write to completed notes
309
+ try {
310
+ const noteContent = readFileSync(notePath, 'utf-8');
311
+ if (noteContent.includes('**Status:** Completed') || noteContent.includes('**Completed:**')) {
312
+ console.error(`Latest note is completed (${basename(notePath)}) — creating new one`);
313
+ notePath = createSessionNote(notesInfo.path, 'Continued Session');
314
+ }
315
+ } catch { /* proceed with existing note */ }
124
316
  }
125
- }
126
317
 
127
- // Save checkpoint to session note before compression
128
- try {
129
- const transcriptDir = dirname(hookInput.transcript_path);
130
- const notesDir = join(transcriptDir, 'Notes');
131
- const currentNotePath = getCurrentNotePath(notesDir);
132
-
133
- 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)}`);
318
+ // 1. Write rich checkpoint with full session state
319
+ const checkpointBody = state
320
+ ? `Context compression triggered at ~${tokenDisplay} tokens with ${stats.messageCount} messages.\n\n${state}`
321
+ : `Context compression triggered at ~${tokenDisplay} tokens with ${stats.messageCount} messages.`;
322
+ appendCheckpoint(notePath, checkpointBody);
323
+
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`);
137
329
  }
330
+
331
+ console.error(`Rich checkpoint saved: ${basename(notePath)}`);
138
332
  } catch (noteError) {
139
333
  console.error(`Could not save checkpoint: ${noteError}`);
140
334
  }
335
+
336
+ // -----------------------------------------------------------------
337
+ // Update TODO.md with checkpoint (like "pause session")
338
+ // -----------------------------------------------------------------
339
+ if (hookInput.cwd && state) {
340
+ try {
341
+ addTodoCheckpoint(hookInput.cwd, `Pre-compact checkpoint (~${tokenDisplay} tokens):\n${state}`);
342
+ console.error('TODO.md checkpoint added');
343
+ } catch (todoError) {
344
+ console.error(`Could not update TODO.md: ${todoError}`);
345
+ }
346
+ }
347
+
348
+ // -----------------------------------------------------------------------
349
+ // Save session state to temp file for post-compact injection.
350
+ //
351
+ // PreCompact hooks have NO stdout support (Claude Code ignores it).
352
+ // Instead, we write the injection payload to a temp file keyed by
353
+ // session_id. The SessionStart(compact) hook reads it and outputs
354
+ // to stdout, which IS injected into the post-compaction context.
355
+ // -----------------------------------------------------------------------
356
+ if (state && hookInput.session_id) {
357
+ const injection = [
358
+ '<system-reminder>',
359
+ `SESSION STATE RECOVERED AFTER COMPACTION (${compactType}, ~${tokenDisplay} tokens)`,
360
+ '',
361
+ state,
362
+ '',
363
+ 'IMPORTANT: This session state was captured before context compaction.',
364
+ 'Use it to maintain continuity. Continue the conversation from where',
365
+ 'it left off without asking the user to repeat themselves.',
366
+ 'Continue with the last task that you were asked to work on.',
367
+ '</system-reminder>',
368
+ ].join('\n');
369
+
370
+ try {
371
+ const stateFile = join(tmpdir(), `pai-compact-state-${hookInput.session_id}.txt`);
372
+ writeFileSync(stateFile, injection, 'utf-8');
373
+ console.error(`Session state saved to ${stateFile} (${injection.length} chars)`);
374
+ } catch (err) {
375
+ console.error(`Failed to save state file: ${err}`);
376
+ }
377
+ }
141
378
  }
142
379
 
143
380
  // Send ntfy.sh notification
@@ -149,7 +386,6 @@ async function main() {
149
386
  process.exit(0);
150
387
  }
151
388
 
152
- // Run the hook
153
389
  main().catch(() => {
154
390
  process.exit(0);
155
391
  });
@@ -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
+ });