@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.
- 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/cleanup-session-files.mjs.map +1 -1
- package/dist/hooks/context-compression-hook.mjs +452 -35
- 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 +2 -2
- package/dist/hooks/post-compact-inject.mjs +51 -0
- package/dist/hooks/post-compact-inject.mjs.map +7 -0
- package/dist/hooks/stop-hook.mjs.map +2 -2
- package/dist/hooks/sync-todo-to-md.mjs.map +2 -2
- package/package.json +1 -1
- package/src/hooks/ts/lib/project-utils.ts +38 -4
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +307 -71
- package/src/hooks/ts/session-start/post-compact-inject.ts +85 -0
|
@@ -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
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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,
|
|
21
|
+
createSessionNote,
|
|
17
22
|
appendCheckpoint,
|
|
18
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
67
|
-
const
|
|
148
|
+
// Build the output — keep it concise
|
|
149
|
+
const parts: string[] = [];
|
|
68
150
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
277
|
+
} catch {
|
|
99
278
|
// Silently handle input errors
|
|
100
279
|
}
|
|
101
280
|
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
});
|