@tekmidian/pai 0.5.3 → 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 +284 -124
- 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/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 +90 -4
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +205 -146
|
@@ -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
|
|
@@ -912,3 +946,55 @@ export function addTodoCheckpoint(cwd: string, checkpoint: string): void {
|
|
|
912
946
|
writeFileSync(todoPath, content);
|
|
913
947
|
console.error(`Checkpoint added to TODO.md`);
|
|
914
948
|
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Update the ## Continue section at the top of TODO.md.
|
|
952
|
+
* This mirrors "pause session" behavior — gives the next session a starting point.
|
|
953
|
+
* Replaces any existing ## Continue section.
|
|
954
|
+
*/
|
|
955
|
+
export function updateTodoContinue(
|
|
956
|
+
cwd: string,
|
|
957
|
+
noteFilename: string,
|
|
958
|
+
state: string | null,
|
|
959
|
+
tokenDisplay: string
|
|
960
|
+
): void {
|
|
961
|
+
const todoPath = ensureTodoMd(cwd);
|
|
962
|
+
let content = readFileSync(todoPath, 'utf-8');
|
|
963
|
+
|
|
964
|
+
// Remove existing ## Continue section (from ## Continue to the first standalone --- line)
|
|
965
|
+
content = content.replace(/## Continue\n[\s\S]*?\n---\n+/, '');
|
|
966
|
+
|
|
967
|
+
const now = new Date().toISOString();
|
|
968
|
+
const stateLines = state
|
|
969
|
+
? state.split('\n').filter(l => l.trim()).slice(0, 10).map(l => `> ${l}`).join('\n')
|
|
970
|
+
: `> Check the latest session note for details.`;
|
|
971
|
+
|
|
972
|
+
const continueSection = `## Continue
|
|
973
|
+
|
|
974
|
+
> **Last session:** ${noteFilename.replace('.md', '')}
|
|
975
|
+
> **Paused at:** ${now}
|
|
976
|
+
>
|
|
977
|
+
${stateLines}
|
|
978
|
+
|
|
979
|
+
---
|
|
980
|
+
|
|
981
|
+
`;
|
|
982
|
+
|
|
983
|
+
// Remove leading whitespace from content
|
|
984
|
+
content = content.replace(/^\s+/, '');
|
|
985
|
+
|
|
986
|
+
// If content starts with # title, insert after it
|
|
987
|
+
const titleMatch = content.match(/^(# [^\n]+\n+)/);
|
|
988
|
+
if (titleMatch) {
|
|
989
|
+
content = titleMatch[1] + continueSection + content.substring(titleMatch[0].length);
|
|
990
|
+
} else {
|
|
991
|
+
content = continueSection + content;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Clean up trailing timestamps and add fresh one
|
|
995
|
+
content = content.replace(/(\n---\s*)*(\n\*Last updated:.*\*\s*)+$/g, '');
|
|
996
|
+
content = content.trimEnd() + `\n\n---\n\n*Last updated: ${now}*\n`;
|
|
997
|
+
|
|
998
|
+
writeFileSync(todoPath, content);
|
|
999
|
+
console.error('TODO.md ## Continue section updated');
|
|
1000
|
+
}
|
|
@@ -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';
|
|
@@ -18,11 +17,12 @@ import { tmpdir } from 'os';
|
|
|
18
17
|
import {
|
|
19
18
|
sendNtfyNotification,
|
|
20
19
|
getCurrentNotePath,
|
|
20
|
+
createSessionNote,
|
|
21
21
|
appendCheckpoint,
|
|
22
22
|
addWorkToSessionNote,
|
|
23
23
|
findNotesDir,
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
renameSessionNote,
|
|
25
|
+
updateTodoContinue,
|
|
26
26
|
calculateSessionTokens,
|
|
27
27
|
WorkItem,
|
|
28
28
|
} from '../lib/project-utils';
|
|
@@ -36,6 +36,16 @@ interface HookInput {
|
|
|
36
36
|
trigger?: string;
|
|
37
37
|
}
|
|
38
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
|
+
|
|
39
49
|
// ---------------------------------------------------------------------------
|
|
40
50
|
// Helpers
|
|
41
51
|
// ---------------------------------------------------------------------------
|
|
@@ -79,24 +89,23 @@ function getTranscriptStats(transcriptPath: string): { messageCount: number; isL
|
|
|
79
89
|
}
|
|
80
90
|
|
|
81
91
|
// ---------------------------------------------------------------------------
|
|
82
|
-
//
|
|
92
|
+
// Unified transcript parser — single pass extracts everything
|
|
83
93
|
// ---------------------------------------------------------------------------
|
|
84
94
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
function parseTranscript(transcriptPath: string): TranscriptData {
|
|
96
|
+
const data: TranscriptData = {
|
|
97
|
+
userMessages: [],
|
|
98
|
+
summaries: [],
|
|
99
|
+
captures: [],
|
|
100
|
+
lastCompleted: '',
|
|
101
|
+
filesModified: [],
|
|
102
|
+
workItems: [],
|
|
103
|
+
};
|
|
104
|
+
|
|
91
105
|
try {
|
|
92
106
|
const raw = readFileSync(transcriptPath, 'utf-8');
|
|
93
107
|
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>();
|
|
108
|
+
const seenSummaries = new Set<string>();
|
|
100
109
|
|
|
101
110
|
for (const line of lines) {
|
|
102
111
|
if (!line.trim()) continue;
|
|
@@ -106,151 +115,165 @@ function extractSessionState(transcriptPath: string, cwd?: string): string | nul
|
|
|
106
115
|
// --- User messages ---
|
|
107
116
|
if (entry.type === 'user' && entry.message?.content) {
|
|
108
117
|
const text = contentToText(entry.message.content).slice(0, 300);
|
|
109
|
-
if (text) userMessages.push(text);
|
|
118
|
+
if (text) data.userMessages.push(text);
|
|
110
119
|
}
|
|
111
120
|
|
|
112
|
-
// --- Assistant
|
|
121
|
+
// --- Assistant content ---
|
|
113
122
|
if (entry.type === 'assistant' && entry.message?.content) {
|
|
114
123
|
const text = contentToText(entry.message.content);
|
|
115
124
|
|
|
125
|
+
// Summaries → also create work items
|
|
116
126
|
const summaryMatch = text.match(/SUMMARY:\s*(.+?)(?:\n|$)/i);
|
|
117
127
|
if (summaryMatch) {
|
|
118
128
|
const s = summaryMatch[1].trim();
|
|
119
|
-
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
|
+
}
|
|
120
144
|
}
|
|
121
145
|
|
|
146
|
+
// Captures
|
|
122
147
|
const captureMatch = text.match(/CAPTURE:\s*(.+?)(?:\n|$)/i);
|
|
123
148
|
if (captureMatch) {
|
|
124
149
|
const c = captureMatch[1].trim();
|
|
125
|
-
if (c.length > 5 && !captures.includes(c)) captures.push(c);
|
|
150
|
+
if (c.length > 5 && !data.captures.includes(c)) data.captures.push(c);
|
|
126
151
|
}
|
|
127
152
|
|
|
153
|
+
// Completed
|
|
128
154
|
const completedMatch = text.match(/COMPLETED:\s*(.+?)(?:\n|$)/i);
|
|
129
155
|
if (completedMatch) {
|
|
130
|
-
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
|
+
}
|
|
131
161
|
}
|
|
132
|
-
}
|
|
133
162
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
}
|
|
141
173
|
}
|
|
142
174
|
}
|
|
143
175
|
}
|
|
144
176
|
}
|
|
145
177
|
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.error(`parseTranscript error: ${err}`);
|
|
180
|
+
}
|
|
146
181
|
|
|
147
|
-
|
|
148
|
-
|
|
182
|
+
return data;
|
|
183
|
+
}
|
|
149
184
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Format session state as human-readable string
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
153
188
|
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
}
|
|
189
|
+
function formatSessionState(data: TranscriptData, cwd?: string): string | null {
|
|
190
|
+
const parts: string[] = [];
|
|
164
191
|
|
|
165
|
-
|
|
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
|
-
}
|
|
192
|
+
if (cwd) parts.push(`Working directory: ${cwd}`);
|
|
173
193
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
parts.push(`- ${c.slice(0, 150)}`);
|
|
180
|
-
}
|
|
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)}`);
|
|
181
199
|
}
|
|
200
|
+
}
|
|
182
201
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
parts.push(`- ${f}`);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
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
|
+
}
|
|
191
207
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
+
}
|
|
195
213
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (data.lastCompleted) {
|
|
221
|
+
parts.push(`\nLast completed: ${data.lastCompleted.slice(0, 150)}`);
|
|
201
222
|
}
|
|
223
|
+
|
|
224
|
+
const result = parts.join('\n');
|
|
225
|
+
return result.length > 50 ? result : null;
|
|
202
226
|
}
|
|
203
227
|
|
|
204
228
|
// ---------------------------------------------------------------------------
|
|
205
|
-
//
|
|
229
|
+
// Derive a meaningful title for the session note
|
|
206
230
|
// ---------------------------------------------------------------------------
|
|
207
231
|
|
|
208
|
-
function
|
|
209
|
-
|
|
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
|
-
}
|
|
232
|
+
function deriveTitle(data: TranscriptData): string {
|
|
233
|
+
let title = '';
|
|
239
234
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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;
|
|
248
256
|
}
|
|
249
257
|
}
|
|
250
|
-
return workItems;
|
|
251
|
-
} catch {
|
|
252
|
-
return [];
|
|
253
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);
|
|
254
277
|
}
|
|
255
278
|
|
|
256
279
|
// ---------------------------------------------------------------------------
|
|
@@ -287,45 +310,81 @@ async function main() {
|
|
|
287
310
|
? `${Math.round(tokenCount / 1000)}k`
|
|
288
311
|
: String(tokenCount);
|
|
289
312
|
|
|
313
|
+
// -----------------------------------------------------------------
|
|
314
|
+
// Single-pass transcript parsing
|
|
315
|
+
// -----------------------------------------------------------------
|
|
316
|
+
const data = parseTranscript(hookInput.transcript_path);
|
|
317
|
+
const state = formatSessionState(data, hookInput.cwd);
|
|
318
|
+
|
|
290
319
|
// -----------------------------------------------------------------
|
|
291
320
|
// Persist session state to numbered session note (like "pause session")
|
|
292
321
|
// -----------------------------------------------------------------
|
|
293
|
-
|
|
322
|
+
let notePath: string | null = null;
|
|
294
323
|
|
|
295
324
|
try {
|
|
296
|
-
// Find notes dir — prefer local, fallback to central
|
|
297
325
|
const notesInfo = hookInput.cwd
|
|
298
326
|
? findNotesDir(hookInput.cwd)
|
|
299
327
|
: { path: join(dirname(hookInput.transcript_path), 'Notes'), isLocal: false };
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
328
|
+
notePath = getCurrentNotePath(notesInfo.path);
|
|
329
|
+
|
|
330
|
+
// If no note found, or the latest note is completed, create a new one
|
|
331
|
+
if (!notePath) {
|
|
332
|
+
console.error('No session note found — creating one for checkpoint');
|
|
333
|
+
notePath = createSessionNote(notesInfo.path, 'Recovered Session');
|
|
334
|
+
} else {
|
|
335
|
+
try {
|
|
336
|
+
const noteContent = readFileSync(notePath, 'utf-8');
|
|
337
|
+
if (noteContent.includes('**Status:** Completed') || noteContent.includes('**Completed:**')) {
|
|
338
|
+
console.error(`Latest note is completed (${basename(notePath)}) — creating new one`);
|
|
339
|
+
notePath = createSessionNote(notesInfo.path, 'Continued Session');
|
|
340
|
+
}
|
|
341
|
+
} catch { /* proceed with existing note */ }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 1. Write rich checkpoint with full session state
|
|
345
|
+
const checkpointBody = state
|
|
346
|
+
? `Context compression triggered at ~${tokenDisplay} tokens with ${stats.messageCount} messages.\n\n${state}`
|
|
347
|
+
: `Context compression triggered at ~${tokenDisplay} tokens with ${stats.messageCount} messages.`;
|
|
348
|
+
appendCheckpoint(notePath, checkpointBody);
|
|
315
349
|
|
|
316
|
-
|
|
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`);
|
|
317
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
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
console.error(`Rich checkpoint saved: ${basename(notePath)}`);
|
|
318
376
|
} catch (noteError) {
|
|
319
377
|
console.error(`Could not save checkpoint: ${noteError}`);
|
|
320
378
|
}
|
|
321
379
|
|
|
322
380
|
// -----------------------------------------------------------------
|
|
323
|
-
// Update TODO.md with
|
|
381
|
+
// Update TODO.md with proper ## Continue section (like "pause session")
|
|
324
382
|
// -----------------------------------------------------------------
|
|
325
|
-
if (hookInput.cwd &&
|
|
383
|
+
if (hookInput.cwd && notePath) {
|
|
326
384
|
try {
|
|
327
|
-
|
|
328
|
-
|
|
385
|
+
const noteFilename = basename(notePath);
|
|
386
|
+
updateTodoContinue(hookInput.cwd, noteFilename, state, tokenDisplay);
|
|
387
|
+
console.error('TODO.md ## Continue section updated');
|
|
329
388
|
} catch (todoError) {
|
|
330
389
|
console.error(`Could not update TODO.md: ${todoError}`);
|
|
331
390
|
}
|