@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.
- package/ARCHITECTURE.md +84 -0
- package/README.md +38 -0
- package/dist/cli/index.mjs +66 -6
- package/dist/cli/index.mjs.map +1 -1
- package/dist/hooks/context-compression-hook.mjs +333 -33
- package/dist/hooks/context-compression-hook.mjs.map +3 -3
- package/dist/hooks/post-compact-inject.mjs +51 -0
- package/dist/hooks/post-compact-inject.mjs.map +7 -0
- package/package.json +1 -1
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +292 -70
- package/src/hooks/ts/session-start/post-compact-inject.ts +85 -0
|
@@ -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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
67
|
-
const
|
|
147
|
+
// Build the output — keep it concise
|
|
148
|
+
const parts: string[] = [];
|
|
68
149
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
276
|
+
} catch {
|
|
99
277
|
// Silently handle input errors
|
|
100
278
|
}
|
|
101
279
|
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
});
|