@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.
@@ -150,6 +150,32 @@ async function sendNtfyNotification(message, retries = 2) {
150
150
  console.error("ntfy.sh notification failed after all retries");
151
151
  return false;
152
152
  }
153
+ function getMonthDir(notesDir) {
154
+ const now = /* @__PURE__ */ new Date();
155
+ const year = String(now.getFullYear());
156
+ const month = String(now.getMonth() + 1).padStart(2, "0");
157
+ const monthDir = join2(notesDir, year, month);
158
+ if (!existsSync2(monthDir)) {
159
+ mkdirSync(monthDir, { recursive: true });
160
+ }
161
+ return monthDir;
162
+ }
163
+ function getNextNoteNumber(notesDir) {
164
+ const monthDir = getMonthDir(notesDir);
165
+ const files = readdirSync(monthDir).filter((f) => f.match(/^\d{3,4}[\s_-]/)).filter((f) => f.endsWith(".md")).sort();
166
+ if (files.length === 0) {
167
+ return "0001";
168
+ }
169
+ let maxNumber = 0;
170
+ for (const file of files) {
171
+ const digitMatch = file.match(/^(\d+)/);
172
+ if (digitMatch) {
173
+ const num = parseInt(digitMatch[1], 10);
174
+ if (num > maxNumber) maxNumber = num;
175
+ }
176
+ }
177
+ return String(maxNumber + 1).padStart(4, "0");
178
+ }
153
179
  function getCurrentNotePath(notesDir) {
154
180
  if (!existsSync2(notesDir)) {
155
181
  return null;
@@ -178,10 +204,77 @@ function getCurrentNotePath(notesDir) {
178
204
  if (prevFound) return prevFound;
179
205
  return findLatestIn(notesDir);
180
206
  }
207
+ function createSessionNote(notesDir, description) {
208
+ const noteNumber = getNextNoteNumber(notesDir);
209
+ const date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
210
+ const safeDescription = "New Session";
211
+ const monthDir = getMonthDir(notesDir);
212
+ const filename = `${noteNumber} - ${date} - ${safeDescription}.md`;
213
+ const filepath = join2(monthDir, filename);
214
+ const content = `# Session ${noteNumber}: ${description}
215
+
216
+ **Date:** ${date}
217
+ **Status:** In Progress
218
+
219
+ ---
220
+
221
+ ## Work Done
222
+
223
+ <!-- PAI will add completed work here during session -->
224
+
225
+ ---
226
+
227
+ ## Next Steps
228
+
229
+ <!-- To be filled at session end -->
230
+
231
+ ---
232
+
233
+ **Tags:** #Session
234
+ `;
235
+ writeFileSync(filepath, content);
236
+ console.error(`Created session note: ${filename}`);
237
+ return filepath;
238
+ }
181
239
  function appendCheckpoint(notePath, checkpoint) {
182
240
  if (!existsSync2(notePath)) {
183
- console.error(`Note file not found: ${notePath}`);
184
- return;
241
+ console.error(`Note file not found, recreating: ${notePath}`);
242
+ try {
243
+ const parentDir = join2(notePath, "..");
244
+ if (!existsSync2(parentDir)) {
245
+ mkdirSync(parentDir, { recursive: true });
246
+ }
247
+ const noteFilename = basename(notePath);
248
+ const numberMatch = noteFilename.match(/^(\d+)/);
249
+ const noteNumber = numberMatch ? numberMatch[1] : "0000";
250
+ const date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
251
+ const content2 = `# Session ${noteNumber}: Recovered
252
+
253
+ **Date:** ${date}
254
+ **Status:** In Progress
255
+
256
+ ---
257
+
258
+ ## Work Done
259
+
260
+ <!-- PAI will add completed work here during session -->
261
+
262
+ ---
263
+
264
+ ## Next Steps
265
+
266
+ <!-- To be filled at session end -->
267
+
268
+ ---
269
+
270
+ **Tags:** #Session
271
+ `;
272
+ writeFileSync(notePath, content2);
273
+ console.error(`Recreated session note: ${noteFilename}`);
274
+ } catch (err) {
275
+ console.error(`Failed to recreate note: ${err}`);
276
+ return;
277
+ }
185
278
  }
186
279
  const content = readFileSync2(notePath, "utf-8");
187
280
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
@@ -237,6 +330,35 @@ function addWorkToSessionNote(notePath, workItems, sectionTitle) {
237
330
  writeFileSync(notePath, content);
238
331
  console.error(`Added ${workItems.length} work item(s) to: ${basename(notePath)}`);
239
332
  }
333
+ function renameSessionNote(notePath, meaningfulName) {
334
+ if (!meaningfulName || !existsSync2(notePath)) {
335
+ return notePath;
336
+ }
337
+ const dir = join2(notePath, "..");
338
+ const oldFilename = basename(notePath);
339
+ const correctMatch = oldFilename.match(/^(\d{3,4}) - (\d{4}-\d{2}-\d{2}) - .*\.md$/);
340
+ const legacyMatch = oldFilename.match(/^(\d{3,4})_(\d{4}-\d{2}-\d{2})_.*\.md$/);
341
+ const match = correctMatch || legacyMatch;
342
+ if (!match) {
343
+ return notePath;
344
+ }
345
+ const [, noteNumber, date] = match;
346
+ const titleCaseName = meaningfulName.split(/[\s_-]+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(" ").trim();
347
+ const paddedNumber = noteNumber.padStart(4, "0");
348
+ const newFilename = `${paddedNumber} - ${date} - ${titleCaseName}.md`;
349
+ const newPath = join2(dir, newFilename);
350
+ if (newFilename === oldFilename) {
351
+ return notePath;
352
+ }
353
+ try {
354
+ renameSync(notePath, newPath);
355
+ console.error(`Renamed note: ${oldFilename} \u2192 ${newFilename}`);
356
+ return newPath;
357
+ } catch (error) {
358
+ console.error(`Could not rename note: ${error}`);
359
+ return notePath;
360
+ }
361
+ }
240
362
  function calculateSessionTokens(jsonlPath) {
241
363
  if (!existsSync2(jsonlPath)) {
242
364
  return 0;
@@ -304,26 +426,38 @@ function ensureTodoMd(cwd) {
304
426
  }
305
427
  return todoPath;
306
428
  }
307
- function addTodoCheckpoint(cwd, checkpoint) {
429
+ function updateTodoContinue(cwd, noteFilename, state, tokenDisplay) {
308
430
  const todoPath = ensureTodoMd(cwd);
309
431
  let content = readFileSync2(todoPath, "utf-8");
310
- content = content.replace(/(\n---\s*)*(\n\*Last updated:.*\*\s*)+$/g, "");
311
- const backlogIndex = content.indexOf("## Backlog");
312
- if (backlogIndex !== -1) {
313
- const checkpointText = `
314
- **Checkpoint (${(/* @__PURE__ */ new Date()).toISOString()}):** ${checkpoint}
432
+ content = content.replace(/## Continue\n[\s\S]*?\n---\n+/, "");
433
+ const now = (/* @__PURE__ */ new Date()).toISOString();
434
+ const stateLines = state ? state.split("\n").filter((l) => l.trim()).slice(0, 10).map((l) => `> ${l}`).join("\n") : `> Check the latest session note for details.`;
435
+ const continueSection = `## Continue
436
+
437
+ > **Last session:** ${noteFilename.replace(".md", "")}
438
+ > **Paused at:** ${now}
439
+ >
440
+ ${stateLines}
441
+
442
+ ---
315
443
 
316
444
  `;
317
- content = content.substring(0, backlogIndex) + checkpointText + content.substring(backlogIndex);
445
+ content = content.replace(/^\s+/, "");
446
+ const titleMatch = content.match(/^(# [^\n]+\n+)/);
447
+ if (titleMatch) {
448
+ content = titleMatch[1] + continueSection + content.substring(titleMatch[0].length);
449
+ } else {
450
+ content = continueSection + content;
318
451
  }
452
+ content = content.replace(/(\n---\s*)*(\n\*Last updated:.*\*\s*)+$/g, "");
319
453
  content = content.trimEnd() + `
320
454
 
321
455
  ---
322
456
 
323
- *Last updated: ${(/* @__PURE__ */ new Date()).toISOString()}*
457
+ *Last updated: ${now}*
324
458
  `;
325
459
  writeFileSync(todoPath, content);
326
- console.error(`Checkpoint added to TODO.md`);
460
+ console.error("TODO.md ## Continue section updated");
327
461
  }
328
462
 
329
463
  // src/hooks/ts/pre-compact/context-compression-hook.ts
@@ -360,15 +494,19 @@ function getTranscriptStats(transcriptPath) {
360
494
  return { messageCount: 0, isLarge: false };
361
495
  }
362
496
  }
363
- function extractSessionState(transcriptPath, cwd) {
497
+ function parseTranscript(transcriptPath) {
498
+ const data = {
499
+ userMessages: [],
500
+ summaries: [],
501
+ captures: [],
502
+ lastCompleted: "",
503
+ filesModified: [],
504
+ workItems: []
505
+ };
364
506
  try {
365
507
  const raw = readFileSync3(transcriptPath, "utf-8");
366
508
  const lines = raw.trim().split("\n");
367
- const userMessages = [];
368
- const summaries = [];
369
- const captures = [];
370
- let lastCompleted = "";
371
- const filesModified = /* @__PURE__ */ new Set();
509
+ const seenSummaries = /* @__PURE__ */ new Set();
372
510
  for (const line of lines) {
373
511
  if (!line.trim()) continue;
374
512
  let entry;
@@ -379,124 +517,117 @@ function extractSessionState(transcriptPath, cwd) {
379
517
  }
380
518
  if (entry.type === "user" && entry.message?.content) {
381
519
  const text = contentToText(entry.message.content).slice(0, 300);
382
- if (text) userMessages.push(text);
520
+ if (text) data.userMessages.push(text);
383
521
  }
384
522
  if (entry.type === "assistant" && entry.message?.content) {
385
523
  const text = contentToText(entry.message.content);
386
524
  const summaryMatch = text.match(/SUMMARY:\s*(.+?)(?:\n|$)/i);
387
525
  if (summaryMatch) {
388
526
  const s = summaryMatch[1].trim();
389
- if (s.length > 5 && !summaries.includes(s)) summaries.push(s);
527
+ if (s.length > 5 && !data.summaries.includes(s)) {
528
+ data.summaries.push(s);
529
+ if (!seenSummaries.has(s)) {
530
+ seenSummaries.add(s);
531
+ const details = [];
532
+ const actionsMatch = text.match(/ACTIONS:\s*(.+?)(?=\n[A-Z]+:|$)/is);
533
+ if (actionsMatch) {
534
+ const actionLines = actionsMatch[1].split("\n").map((l) => l.replace(/^[-*•]\s*/, "").replace(/^\d+\.\s*/, "").trim()).filter((l) => l.length > 3 && l.length < 100);
535
+ details.push(...actionLines.slice(0, 3));
536
+ }
537
+ data.workItems.push({ title: s, details: details.length > 0 ? details : void 0, completed: true });
538
+ }
539
+ }
390
540
  }
391
541
  const captureMatch = text.match(/CAPTURE:\s*(.+?)(?:\n|$)/i);
392
542
  if (captureMatch) {
393
543
  const c = captureMatch[1].trim();
394
- if (c.length > 5 && !captures.includes(c)) captures.push(c);
544
+ if (c.length > 5 && !data.captures.includes(c)) data.captures.push(c);
395
545
  }
396
546
  const completedMatch = text.match(/COMPLETED:\s*(.+?)(?:\n|$)/i);
397
547
  if (completedMatch) {
398
- lastCompleted = completedMatch[1].trim().replace(/\*+/g, "");
548
+ data.lastCompleted = completedMatch[1].trim().replace(/\*+/g, "");
549
+ if (data.workItems.length === 0 && !seenSummaries.has(data.lastCompleted) && data.lastCompleted.length > 5) {
550
+ seenSummaries.add(data.lastCompleted);
551
+ data.workItems.push({ title: data.lastCompleted, completed: true });
552
+ }
399
553
  }
400
- }
401
- if (entry.type === "assistant" && entry.message?.content && Array.isArray(entry.message.content)) {
402
- for (const block of entry.message.content) {
403
- if (block.type === "tool_use") {
404
- const tool = block.name;
405
- if ((tool === "Edit" || tool === "Write") && block.input?.file_path) {
406
- filesModified.add(block.input.file_path);
554
+ if (Array.isArray(entry.message.content)) {
555
+ for (const block of entry.message.content) {
556
+ if (block.type === "tool_use") {
557
+ const tool = block.name;
558
+ if ((tool === "Edit" || tool === "Write") && block.input?.file_path) {
559
+ if (!data.filesModified.includes(block.input.file_path)) {
560
+ data.filesModified.push(block.input.file_path);
561
+ }
562
+ }
407
563
  }
408
564
  }
409
565
  }
410
566
  }
411
567
  }
412
- const parts = [];
413
- if (cwd) {
414
- parts.push(`Working directory: ${cwd}`);
415
- }
416
- const recentUser = userMessages.slice(-3);
417
- if (recentUser.length > 0) {
418
- parts.push("\nRecent user requests:");
419
- for (const msg of recentUser) {
420
- const firstLine = msg.split("\n")[0].slice(0, 200);
421
- parts.push(`- ${firstLine}`);
422
- }
423
- }
424
- const recentSummaries = summaries.slice(-3);
425
- if (recentSummaries.length > 0) {
426
- parts.push("\nWork summaries:");
427
- for (const s of recentSummaries) {
428
- parts.push(`- ${s.slice(0, 150)}`);
429
- }
430
- }
431
- const recentCaptures = captures.slice(-5);
432
- if (recentCaptures.length > 0) {
433
- parts.push("\nCaptured context:");
434
- for (const c of recentCaptures) {
435
- parts.push(`- ${c.slice(0, 150)}`);
436
- }
437
- }
438
- const files = Array.from(filesModified).slice(-10);
439
- if (files.length > 0) {
440
- parts.push("\nFiles modified this session:");
441
- for (const f of files) {
442
- parts.push(`- ${f}`);
443
- }
444
- }
445
- if (lastCompleted) {
446
- parts.push(`
447
- Last completed: ${lastCompleted.slice(0, 150)}`);
448
- }
449
- const result = parts.join("\n");
450
- return result.length > 50 ? result : null;
451
568
  } catch (err) {
452
- console.error(`extractSessionState error: ${err}`);
453
- return null;
569
+ console.error(`parseTranscript error: ${err}`);
454
570
  }
571
+ return data;
455
572
  }
456
- function extractWorkFromTranscript(transcriptPath) {
457
- try {
458
- const raw = readFileSync3(transcriptPath, "utf-8");
459
- const lines = raw.trim().split("\n");
460
- const workItems = [];
461
- const seenSummaries = /* @__PURE__ */ new Set();
462
- for (const line of lines) {
463
- if (!line.trim()) continue;
464
- let entry;
465
- try {
466
- entry = JSON.parse(line);
467
- } catch {
468
- continue;
469
- }
470
- if (entry.type === "assistant" && entry.message?.content) {
471
- const text = contentToText(entry.message.content);
472
- const summaryMatch = text.match(/SUMMARY:\s*(.+?)(?:\n|$)/i);
473
- if (summaryMatch) {
474
- const summary = summaryMatch[1].trim();
475
- if (summary && !seenSummaries.has(summary) && summary.length > 5) {
476
- seenSummaries.add(summary);
477
- const details = [];
478
- const actionsMatch = text.match(/ACTIONS:\s*(.+?)(?=\n[A-Z]+:|$)/is);
479
- if (actionsMatch) {
480
- const actionLines = actionsMatch[1].split("\n").map((l) => l.replace(/^[-*•]\s*/, "").replace(/^\d+\.\s*/, "").trim()).filter((l) => l.length > 3 && l.length < 100);
481
- details.push(...actionLines.slice(0, 3));
482
- }
483
- workItems.push({ title: summary, details: details.length > 0 ? details : void 0, completed: true });
484
- }
485
- }
486
- const completedMatch = text.match(/COMPLETED:\s*(.+?)(?:\n|$)/i);
487
- if (completedMatch && workItems.length === 0) {
488
- const completed = completedMatch[1].trim().replace(/\*+/g, "");
489
- if (completed && !seenSummaries.has(completed) && completed.length > 5) {
490
- seenSummaries.add(completed);
491
- workItems.push({ title: completed, completed: true });
492
- }
493
- }
573
+ function formatSessionState(data, cwd) {
574
+ const parts = [];
575
+ if (cwd) parts.push(`Working directory: ${cwd}`);
576
+ const recentUser = data.userMessages.slice(-3);
577
+ if (recentUser.length > 0) {
578
+ parts.push("\nRecent user requests:");
579
+ for (const msg of recentUser) {
580
+ parts.push(`- ${msg.split("\n")[0].slice(0, 200)}`);
581
+ }
582
+ }
583
+ const recentSummaries = data.summaries.slice(-3);
584
+ if (recentSummaries.length > 0) {
585
+ parts.push("\nWork summaries:");
586
+ for (const s of recentSummaries) parts.push(`- ${s.slice(0, 150)}`);
587
+ }
588
+ const recentCaptures = data.captures.slice(-5);
589
+ if (recentCaptures.length > 0) {
590
+ parts.push("\nCaptured context:");
591
+ for (const c of recentCaptures) parts.push(`- ${c.slice(0, 150)}`);
592
+ }
593
+ const files = data.filesModified.slice(-10);
594
+ if (files.length > 0) {
595
+ parts.push("\nFiles modified this session:");
596
+ for (const f of files) parts.push(`- ${f}`);
597
+ }
598
+ if (data.lastCompleted) {
599
+ parts.push(`
600
+ Last completed: ${data.lastCompleted.slice(0, 150)}`);
601
+ }
602
+ const result = parts.join("\n");
603
+ return result.length > 50 ? result : null;
604
+ }
605
+ function deriveTitle(data) {
606
+ let title = "";
607
+ if (data.workItems.length > 0) {
608
+ title = data.workItems[data.workItems.length - 1].title;
609
+ } else if (data.summaries.length > 0) {
610
+ title = data.summaries[data.summaries.length - 1];
611
+ } else if (data.lastCompleted && data.lastCompleted.length > 5) {
612
+ title = data.lastCompleted;
613
+ } else if (data.userMessages.length > 0) {
614
+ for (let i = data.userMessages.length - 1; i >= 0; i--) {
615
+ const msg = data.userMessages[i].split("\n")[0].trim();
616
+ if (msg.length > 10 && msg.length < 80 && !msg.toLowerCase().startsWith("yes") && !msg.toLowerCase().startsWith("ok")) {
617
+ title = msg;
618
+ break;
494
619
  }
495
620
  }
496
- return workItems;
497
- } catch {
498
- return [];
499
621
  }
622
+ if (!title && data.filesModified.length > 0) {
623
+ const basenames = data.filesModified.slice(-5).map((f) => {
624
+ const b = basename2(f);
625
+ return b.replace(/\.[^.]+$/, "");
626
+ });
627
+ const unique = [...new Set(basenames)];
628
+ title = unique.length <= 3 ? `Updated ${unique.join(", ")}` : `Modified ${data.filesModified.length} files`;
629
+ }
630
+ return title.replace(/[^\w\s-]/g, " ").replace(/\s+/g, " ").trim().substring(0, 60);
500
631
  }
501
632
  async function main() {
502
633
  let hookInput = null;
@@ -523,30 +654,59 @@ async function main() {
523
654
  const stats = getTranscriptStats(hookInput.transcript_path);
524
655
  tokenCount = calculateSessionTokens(hookInput.transcript_path);
525
656
  const tokenDisplay = tokenCount > 1e3 ? `${Math.round(tokenCount / 1e3)}k` : String(tokenCount);
526
- const state = extractSessionState(hookInput.transcript_path, hookInput.cwd);
657
+ const data = parseTranscript(hookInput.transcript_path);
658
+ const state = formatSessionState(data, hookInput.cwd);
659
+ let notePath = null;
527
660
  try {
528
661
  const notesInfo = hookInput.cwd ? findNotesDir(hookInput.cwd) : { path: join3(dirname(hookInput.transcript_path), "Notes"), isLocal: false };
529
- const currentNotePath = getCurrentNotePath(notesInfo.path);
530
- if (currentNotePath) {
531
- const checkpointBody = state ? `Context compression triggered at ~${tokenDisplay} tokens with ${stats.messageCount} messages.
662
+ notePath = getCurrentNotePath(notesInfo.path);
663
+ if (!notePath) {
664
+ console.error("No session note found \u2014 creating one for checkpoint");
665
+ notePath = createSessionNote(notesInfo.path, "Recovered Session");
666
+ } else {
667
+ try {
668
+ const noteContent = readFileSync3(notePath, "utf-8");
669
+ if (noteContent.includes("**Status:** Completed") || noteContent.includes("**Completed:**")) {
670
+ console.error(`Latest note is completed (${basename2(notePath)}) \u2014 creating new one`);
671
+ notePath = createSessionNote(notesInfo.path, "Continued Session");
672
+ }
673
+ } catch {
674
+ }
675
+ }
676
+ const checkpointBody = state ? `Context compression triggered at ~${tokenDisplay} tokens with ${stats.messageCount} messages.
532
677
 
533
678
  ${state}` : `Context compression triggered at ~${tokenDisplay} tokens with ${stats.messageCount} messages.`;
534
- appendCheckpoint(currentNotePath, checkpointBody);
535
- const workItems = extractWorkFromTranscript(hookInput.transcript_path);
536
- if (workItems.length > 0) {
537
- addWorkToSessionNote(currentNotePath, workItems, `Pre-Compact (~${tokenDisplay} tokens)`);
538
- console.error(`Added ${workItems.length} work item(s) to session note`);
679
+ appendCheckpoint(notePath, checkpointBody);
680
+ if (data.workItems.length > 0) {
681
+ addWorkToSessionNote(notePath, data.workItems, `Pre-Compact (~${tokenDisplay} tokens)`);
682
+ console.error(`Added ${data.workItems.length} work item(s) to session note`);
683
+ }
684
+ const title = deriveTitle(data);
685
+ if (title) {
686
+ const newPath = renameSessionNote(notePath, title);
687
+ if (newPath !== notePath) {
688
+ try {
689
+ let noteContent = readFileSync3(newPath, "utf-8");
690
+ noteContent = noteContent.replace(
691
+ /^(# Session \d+:)\s*.*$/m,
692
+ `$1 ${title}`
693
+ );
694
+ writeFileSync2(newPath, noteContent);
695
+ console.error(`Updated note H1 to match rename`);
696
+ } catch {
697
+ }
698
+ notePath = newPath;
539
699
  }
540
- console.error(`Rich checkpoint saved: ${basename2(currentNotePath)}`);
541
700
  }
701
+ console.error(`Rich checkpoint saved: ${basename2(notePath)}`);
542
702
  } catch (noteError) {
543
703
  console.error(`Could not save checkpoint: ${noteError}`);
544
704
  }
545
- if (hookInput.cwd && state) {
705
+ if (hookInput.cwd && notePath) {
546
706
  try {
547
- addTodoCheckpoint(hookInput.cwd, `Pre-compact checkpoint (~${tokenDisplay} tokens):
548
- ${state}`);
549
- console.error("TODO.md checkpoint added");
707
+ const noteFilename = basename2(notePath);
708
+ updateTodoContinue(hookInput.cwd, noteFilename, state, tokenDisplay);
709
+ console.error("TODO.md ## Continue section updated");
550
710
  } catch (todoError) {
551
711
  console.error(`Could not update TODO.md: ${todoError}`);
552
712
  }