@tekmidian/pai 0.3.2 → 0.4.0

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.
Files changed (57) hide show
  1. package/dist/cli/index.mjs +279 -21
  2. package/dist/cli/index.mjs.map +1 -1
  3. package/dist/hooks/capture-all-events.mjs +238 -0
  4. package/dist/hooks/capture-all-events.mjs.map +7 -0
  5. package/dist/hooks/capture-session-summary.mjs +198 -0
  6. package/dist/hooks/capture-session-summary.mjs.map +7 -0
  7. package/dist/hooks/capture-tool-output.mjs +105 -0
  8. package/dist/hooks/capture-tool-output.mjs.map +7 -0
  9. package/dist/hooks/cleanup-session-files.mjs +129 -0
  10. package/dist/hooks/cleanup-session-files.mjs.map +7 -0
  11. package/dist/hooks/context-compression-hook.mjs +283 -0
  12. package/dist/hooks/context-compression-hook.mjs.map +7 -0
  13. package/dist/hooks/initialize-session.mjs +206 -0
  14. package/dist/hooks/initialize-session.mjs.map +7 -0
  15. package/dist/hooks/load-core-context.mjs +110 -0
  16. package/dist/hooks/load-core-context.mjs.map +7 -0
  17. package/dist/hooks/load-project-context.mjs +548 -0
  18. package/dist/hooks/load-project-context.mjs.map +7 -0
  19. package/dist/hooks/security-validator.mjs +159 -0
  20. package/dist/hooks/security-validator.mjs.map +7 -0
  21. package/dist/hooks/stop-hook.mjs +625 -0
  22. package/dist/hooks/stop-hook.mjs.map +7 -0
  23. package/dist/hooks/subagent-stop-hook.mjs +152 -0
  24. package/dist/hooks/subagent-stop-hook.mjs.map +7 -0
  25. package/dist/hooks/sync-todo-to-md.mjs +322 -0
  26. package/dist/hooks/sync-todo-to-md.mjs.map +7 -0
  27. package/dist/hooks/update-tab-on-action.mjs +90 -0
  28. package/dist/hooks/update-tab-on-action.mjs.map +7 -0
  29. package/dist/hooks/update-tab-titles.mjs +55 -0
  30. package/dist/hooks/update-tab-titles.mjs.map +7 -0
  31. package/package.json +4 -2
  32. package/scripts/build-hooks.mjs +51 -0
  33. package/src/hooks/ts/capture-all-events.ts +179 -0
  34. package/src/hooks/ts/lib/detect-environment.ts +53 -0
  35. package/src/hooks/ts/lib/metadata-extraction.ts +144 -0
  36. package/src/hooks/ts/lib/pai-paths.ts +124 -0
  37. package/src/hooks/ts/lib/project-utils.ts +914 -0
  38. package/src/hooks/ts/post-tool-use/capture-tool-output.ts +78 -0
  39. package/src/hooks/ts/post-tool-use/sync-todo-to-md.ts +230 -0
  40. package/src/hooks/ts/post-tool-use/update-tab-on-action.ts +145 -0
  41. package/src/hooks/ts/pre-compact/context-compression-hook.ts +155 -0
  42. package/src/hooks/ts/pre-tool-use/security-validator.ts +258 -0
  43. package/src/hooks/ts/session-end/capture-session-summary.ts +185 -0
  44. package/src/hooks/ts/session-start/initialize-session.ts +155 -0
  45. package/src/hooks/ts/session-start/load-core-context.ts +104 -0
  46. package/src/hooks/ts/session-start/load-project-context.ts +394 -0
  47. package/src/hooks/ts/stop/stop-hook.ts +407 -0
  48. package/src/hooks/ts/subagent-stop/subagent-stop-hook.ts +212 -0
  49. package/src/hooks/ts/user-prompt/cleanup-session-files.ts +45 -0
  50. package/src/hooks/ts/user-prompt/update-tab-titles.ts +88 -0
  51. package/tab-color-command.sh +24 -0
  52. package/templates/skills/createskill-skill.template.md +78 -0
  53. package/templates/skills/history-system.template.md +371 -0
  54. package/templates/skills/hook-system.template.md +913 -0
  55. package/templates/skills/sessions-skill.template.md +102 -0
  56. package/templates/skills/skill-system.template.md +214 -0
  57. package/templates/skills/terminal-tabs.template.md +120 -0
@@ -0,0 +1,914 @@
1
+ /**
2
+ * project-utils.ts - Shared utilities for project context management
3
+ *
4
+ * Provides:
5
+ * - Path encoding (matching Claude Code's scheme)
6
+ * - ntfy.sh notifications (mandatory, synchronous)
7
+ * - Session notes management
8
+ * - Session token calculation
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync } from 'fs';
12
+ import { join, basename } from 'path';
13
+
14
+ // Import from pai-paths which handles .env loading and path resolution
15
+ import { PAI_DIR } from './pai-paths.js';
16
+
17
+ // Re-export PAI_DIR for consumers
18
+ export { PAI_DIR };
19
+ export const PROJECTS_DIR = join(PAI_DIR, 'projects');
20
+
21
+ /**
22
+ * Encode a path the same way Claude Code does:
23
+ * - Replace / with -
24
+ * - Replace . with - (hidden directories become --name)
25
+ *
26
+ * This matches Claude Code's internal encoding to ensure Notes
27
+ * are stored in the same project directory as transcripts.
28
+ */
29
+ export function encodePath(path: string): string {
30
+ return path
31
+ .replace(/\//g, '-') // Slashes become dashes
32
+ .replace(/\./g, '-') // Dots also become dashes
33
+ .replace(/ /g, '-'); // Spaces become dashes (matches Claude Code native encoding)
34
+ }
35
+
36
+ /**
37
+ * Get the project directory for a given working directory
38
+ */
39
+ export function getProjectDir(cwd: string): string {
40
+ const encoded = encodePath(cwd);
41
+ return join(PROJECTS_DIR, encoded);
42
+ }
43
+
44
+ /**
45
+ * Get the Notes directory for a project (central location)
46
+ */
47
+ export function getNotesDir(cwd: string): string {
48
+ return join(getProjectDir(cwd), 'Notes');
49
+ }
50
+
51
+ /**
52
+ * Find Notes directory - check local first, fallback to central
53
+ * DOES NOT create the directory - just finds the right location
54
+ *
55
+ * Logic:
56
+ * - If cwd itself IS a Notes directory → use it directly
57
+ * - If local Notes/ exists → use it (can be checked into git)
58
+ * - Otherwise → use central ~/.claude/projects/.../Notes/
59
+ */
60
+ export function findNotesDir(cwd: string): { path: string; isLocal: boolean } {
61
+ // FIRST: Check if cwd itself IS a Notes directory
62
+ const cwdBasename = basename(cwd).toLowerCase();
63
+ if (cwdBasename === 'notes' && existsSync(cwd)) {
64
+ return { path: cwd, isLocal: true };
65
+ }
66
+
67
+ // Check local locations
68
+ const localPaths = [
69
+ join(cwd, 'Notes'),
70
+ join(cwd, 'notes'),
71
+ join(cwd, '.claude', 'Notes')
72
+ ];
73
+
74
+ for (const path of localPaths) {
75
+ if (existsSync(path)) {
76
+ return { path, isLocal: true };
77
+ }
78
+ }
79
+
80
+ // Fallback to central location
81
+ return { path: getNotesDir(cwd), isLocal: false };
82
+ }
83
+
84
+ /**
85
+ * Get the Sessions directory for a project (stores .jsonl transcripts)
86
+ */
87
+ export function getSessionsDir(cwd: string): string {
88
+ return join(getProjectDir(cwd), 'sessions');
89
+ }
90
+
91
+ /**
92
+ * Get the Sessions directory from a project directory path
93
+ */
94
+ export function getSessionsDirFromProjectDir(projectDir: string): string {
95
+ return join(projectDir, 'sessions');
96
+ }
97
+
98
+ /**
99
+ * Check if WhatsApp (Whazaa) is configured as an enabled MCP server.
100
+ *
101
+ * Uses standard Claude Code config at ~/.claude/settings.json.
102
+ * No PAI dependency — works for any Claude Code user with whazaa installed.
103
+ */
104
+ export function isWhatsAppEnabled(): boolean {
105
+ try {
106
+ const { homedir } = require('os');
107
+ const settingsPath = join(homedir(), '.claude', 'settings.json');
108
+ if (!existsSync(settingsPath)) return false;
109
+
110
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
111
+ const enabled: string[] = settings.enabledMcpjsonServers || [];
112
+ return enabled.includes('whazaa');
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Send push notification — WhatsApp-aware with ntfy fallback.
120
+ *
121
+ * When WhatsApp (Whazaa) is enabled in MCP config, ntfy is SKIPPED
122
+ * because the AI sends WhatsApp messages directly via MCP. Sending both
123
+ * would cause duplicate notifications.
124
+ *
125
+ * When WhatsApp is NOT configured, ntfy fires as the fallback channel.
126
+ */
127
+ export async function sendNtfyNotification(message: string, retries = 2): Promise<boolean> {
128
+ // Skip ntfy when WhatsApp is configured — the AI handles notifications via MCP
129
+ if (isWhatsAppEnabled()) {
130
+ console.error(`WhatsApp (Whazaa) enabled in MCP config — skipping ntfy`);
131
+ return true;
132
+ }
133
+
134
+ const topic = process.env.NTFY_TOPIC;
135
+
136
+ if (!topic) {
137
+ console.error('NTFY_TOPIC not set and WhatsApp not active — notifications disabled');
138
+ return false;
139
+ }
140
+
141
+ for (let attempt = 0; attempt <= retries; attempt++) {
142
+ try {
143
+ const response = await fetch(`https://ntfy.sh/${topic}`, {
144
+ method: 'POST',
145
+ body: message,
146
+ headers: {
147
+ 'Title': 'Claude Code',
148
+ 'Priority': 'default'
149
+ }
150
+ });
151
+
152
+ if (response.ok) {
153
+ console.error(`ntfy.sh notification sent (WhatsApp inactive): "${message}"`);
154
+ return true;
155
+ } else {
156
+ console.error(`ntfy.sh attempt ${attempt + 1} failed: ${response.status}`);
157
+ }
158
+ } catch (error) {
159
+ console.error(`ntfy.sh attempt ${attempt + 1} error: ${error}`);
160
+ }
161
+
162
+ // Wait before retry
163
+ if (attempt < retries) {
164
+ await new Promise(resolve => setTimeout(resolve, 1000));
165
+ }
166
+ }
167
+
168
+ console.error('ntfy.sh notification failed after all retries');
169
+ return false;
170
+ }
171
+
172
+ /**
173
+ * Ensure the Notes directory exists for a project
174
+ * DEPRECATED: Use ensureNotesDirSmart() instead
175
+ */
176
+ export function ensureNotesDir(cwd: string): string {
177
+ const notesDir = getNotesDir(cwd);
178
+
179
+ if (!existsSync(notesDir)) {
180
+ mkdirSync(notesDir, { recursive: true });
181
+ console.error(`Created Notes directory: ${notesDir}`);
182
+ }
183
+
184
+ return notesDir;
185
+ }
186
+
187
+ /**
188
+ * Smart Notes directory handling:
189
+ * - If local Notes/ exists → use it (don't create anything new)
190
+ * - If no local Notes/ → ensure central exists and use that
191
+ *
192
+ * This respects the user's choice:
193
+ * - Projects with local Notes/ keep notes there (git-trackable)
194
+ * - Other directories don't get cluttered with auto-created Notes/
195
+ */
196
+ export function ensureNotesDirSmart(cwd: string): { path: string; isLocal: boolean } {
197
+ const found = findNotesDir(cwd);
198
+
199
+ if (found.isLocal) {
200
+ // Local Notes/ exists - use it as-is
201
+ return found;
202
+ }
203
+
204
+ // No local Notes/ - ensure central exists
205
+ if (!existsSync(found.path)) {
206
+ mkdirSync(found.path, { recursive: true });
207
+ console.error(`Created central Notes directory: ${found.path}`);
208
+ }
209
+
210
+ return found;
211
+ }
212
+
213
+ /**
214
+ * Ensure the Sessions directory exists for a project
215
+ */
216
+ export function ensureSessionsDir(cwd: string): string {
217
+ const sessionsDir = getSessionsDir(cwd);
218
+
219
+ if (!existsSync(sessionsDir)) {
220
+ mkdirSync(sessionsDir, { recursive: true });
221
+ console.error(`Created sessions directory: ${sessionsDir}`);
222
+ }
223
+
224
+ return sessionsDir;
225
+ }
226
+
227
+ /**
228
+ * Ensure the Sessions directory exists (from project dir path)
229
+ */
230
+ export function ensureSessionsDirFromProjectDir(projectDir: string): string {
231
+ const sessionsDir = getSessionsDirFromProjectDir(projectDir);
232
+
233
+ if (!existsSync(sessionsDir)) {
234
+ mkdirSync(sessionsDir, { recursive: true });
235
+ console.error(`Created sessions directory: ${sessionsDir}`);
236
+ }
237
+
238
+ return sessionsDir;
239
+ }
240
+
241
+ /**
242
+ * Move all .jsonl session files from project root to sessions/ subdirectory
243
+ * @param projectDir - The project directory path
244
+ * @param excludeFile - Optional filename to exclude (e.g., current active session)
245
+ * @param silent - If true, suppress console output
246
+ * Returns the number of files moved
247
+ */
248
+ export function moveSessionFilesToSessionsDir(
249
+ projectDir: string,
250
+ excludeFile?: string,
251
+ silent: boolean = false
252
+ ): number {
253
+ const sessionsDir = ensureSessionsDirFromProjectDir(projectDir);
254
+
255
+ if (!existsSync(projectDir)) {
256
+ return 0;
257
+ }
258
+
259
+ const files = readdirSync(projectDir);
260
+ let movedCount = 0;
261
+
262
+ for (const file of files) {
263
+ // Match session files: uuid.jsonl or agent-*.jsonl
264
+ // Skip the excluded file (typically the current active session)
265
+ if (file.endsWith('.jsonl') && file !== excludeFile) {
266
+ const sourcePath = join(projectDir, file);
267
+ const destPath = join(sessionsDir, file);
268
+
269
+ try {
270
+ renameSync(sourcePath, destPath);
271
+ if (!silent) {
272
+ console.error(`Moved ${file} → sessions/`);
273
+ }
274
+ movedCount++;
275
+ } catch (error) {
276
+ if (!silent) {
277
+ console.error(`Could not move ${file}: ${error}`);
278
+ }
279
+ }
280
+ }
281
+ }
282
+
283
+ return movedCount;
284
+ }
285
+
286
+ /**
287
+ * Get the YYYY/MM subdirectory for the current month inside notesDir.
288
+ * Creates the directory if it doesn't exist.
289
+ */
290
+ function getMonthDir(notesDir: string): string {
291
+ const now = new Date();
292
+ const year = String(now.getFullYear());
293
+ const month = String(now.getMonth() + 1).padStart(2, '0');
294
+ const monthDir = join(notesDir, year, month);
295
+ if (!existsSync(monthDir)) {
296
+ mkdirSync(monthDir, { recursive: true });
297
+ }
298
+ return monthDir;
299
+ }
300
+
301
+ /**
302
+ * Get the next note number (4-digit format: 0001, 0002, etc.)
303
+ * ALWAYS uses 4-digit format with space-dash-space separators
304
+ * Format: NNNN - YYYY-MM-DD - Description.md
305
+ * Numbers reset per month (each YYYY/MM directory has its own sequence).
306
+ */
307
+ export function getNextNoteNumber(notesDir: string): string {
308
+ const monthDir = getMonthDir(notesDir);
309
+
310
+ // Match CORRECT format: "0001 - " (4-digit with space-dash-space)
311
+ // Also match legacy formats for backwards compatibility when detecting max number
312
+ const files = readdirSync(monthDir)
313
+ .filter(f => f.match(/^\d{3,4}[\s_-]/)) // Starts with 3-4 digits followed by separator
314
+ .filter(f => f.endsWith('.md'))
315
+ .sort();
316
+
317
+ if (files.length === 0) {
318
+ return '0001'; // Default to 4-digit
319
+ }
320
+
321
+ // Find the highest number across all formats
322
+ let maxNumber = 0;
323
+ for (const file of files) {
324
+ const digitMatch = file.match(/^(\d+)/);
325
+ if (digitMatch) {
326
+ const num = parseInt(digitMatch[1], 10);
327
+ if (num > maxNumber) maxNumber = num;
328
+ }
329
+ }
330
+
331
+ // ALWAYS return 4-digit format
332
+ return String(maxNumber + 1).padStart(4, '0');
333
+ }
334
+
335
+ /**
336
+ * Get the current (latest) note file path, or null if none exists.
337
+ * Searches in the current month's YYYY/MM subdirectory first,
338
+ * then falls back to previous month (for sessions spanning month boundaries),
339
+ * then falls back to flat notesDir for legacy notes.
340
+ * Supports multiple formats for backwards compatibility:
341
+ * - CORRECT: "0001 - YYYY-MM-DD - Description.md" (space-dash-space)
342
+ * - Legacy: "001_YYYY-MM-DD_description.md" (underscores)
343
+ */
344
+ export function getCurrentNotePath(notesDir: string): string | null {
345
+ if (!existsSync(notesDir)) {
346
+ return null;
347
+ }
348
+
349
+ // Helper: find latest session note in a directory
350
+ const findLatestIn = (dir: string): string | null => {
351
+ if (!existsSync(dir)) return null;
352
+ const files = readdirSync(dir)
353
+ .filter(f => f.match(/^\d{3,4}[\s_-].*\.md$/))
354
+ .sort((a, b) => {
355
+ const numA = parseInt(a.match(/^(\d+)/)?.[1] || '0', 10);
356
+ const numB = parseInt(b.match(/^(\d+)/)?.[1] || '0', 10);
357
+ return numA - numB;
358
+ });
359
+ if (files.length === 0) return null;
360
+ return join(dir, files[files.length - 1]);
361
+ };
362
+
363
+ // 1. Check current month's YYYY/MM directory
364
+ const now = new Date();
365
+ const year = String(now.getFullYear());
366
+ const month = String(now.getMonth() + 1).padStart(2, '0');
367
+ const currentMonthDir = join(notesDir, year, month);
368
+ const found = findLatestIn(currentMonthDir);
369
+ if (found) return found;
370
+
371
+ // 2. Check previous month (for sessions spanning month boundaries)
372
+ const prevDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
373
+ const prevYear = String(prevDate.getFullYear());
374
+ const prevMonth = String(prevDate.getMonth() + 1).padStart(2, '0');
375
+ const prevMonthDir = join(notesDir, prevYear, prevMonth);
376
+ const prevFound = findLatestIn(prevMonthDir);
377
+ if (prevFound) return prevFound;
378
+
379
+ // 3. Fallback: check flat notesDir (legacy notes not yet filed)
380
+ return findLatestIn(notesDir);
381
+ }
382
+
383
+ /**
384
+ * Create a new session note
385
+ * CORRECT FORMAT: "NNNN - YYYY-MM-DD - Description.md"
386
+ * - 4-digit zero-padded number
387
+ * - Space-dash-space separators (NOT underscores)
388
+ * - Title case description
389
+ *
390
+ * IMPORTANT: The initial description is just a PLACEHOLDER.
391
+ * Claude MUST rename the file at session end with a meaningful description
392
+ * based on the actual work done. Never leave it as "New Session" or project name.
393
+ */
394
+ export function createSessionNote(notesDir: string, description: string): string {
395
+ const noteNumber = getNextNoteNumber(notesDir);
396
+ const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
397
+
398
+ // Use "New Session" as placeholder - Claude MUST rename at session end!
399
+ // The project name alone is NOT descriptive enough.
400
+ const safeDescription = 'New Session';
401
+
402
+ // CORRECT FORMAT: space-dash-space separators, filed into YYYY/MM subdirectory
403
+ const monthDir = getMonthDir(notesDir);
404
+ const filename = `${noteNumber} - ${date} - ${safeDescription}.md`;
405
+ const filepath = join(monthDir, filename);
406
+
407
+ const content = `# Session ${noteNumber}: ${description}
408
+
409
+ **Date:** ${date}
410
+ **Status:** In Progress
411
+
412
+ ---
413
+
414
+ ## Work Done
415
+
416
+ <!-- PAI will add completed work here during session -->
417
+
418
+ ---
419
+
420
+ ## Next Steps
421
+
422
+ <!-- To be filled at session end -->
423
+
424
+ ---
425
+
426
+ **Tags:** #Session
427
+ `;
428
+
429
+ writeFileSync(filepath, content);
430
+ console.error(`Created session note: ${filename}`);
431
+
432
+ return filepath;
433
+ }
434
+
435
+ /**
436
+ * Append checkpoint to current session note
437
+ */
438
+ export function appendCheckpoint(notePath: string, checkpoint: string): void {
439
+ if (!existsSync(notePath)) {
440
+ console.error(`Note file not found: ${notePath}`);
441
+ return;
442
+ }
443
+
444
+ const content = readFileSync(notePath, 'utf-8');
445
+ const timestamp = new Date().toISOString();
446
+ const checkpointText = `\n### Checkpoint ${timestamp}\n\n${checkpoint}\n`;
447
+
448
+ // Insert before "## Next Steps" if it exists, otherwise append
449
+ const nextStepsIndex = content.indexOf('## Next Steps');
450
+ let newContent: string;
451
+
452
+ if (nextStepsIndex !== -1) {
453
+ newContent = content.substring(0, nextStepsIndex) + checkpointText + content.substring(nextStepsIndex);
454
+ } else {
455
+ newContent = content + checkpointText;
456
+ }
457
+
458
+ writeFileSync(notePath, newContent);
459
+ console.error(`Checkpoint added to: ${basename(notePath)}`);
460
+ }
461
+
462
+ /**
463
+ * Work item for session notes
464
+ */
465
+ export interface WorkItem {
466
+ title: string;
467
+ details?: string[];
468
+ completed?: boolean;
469
+ }
470
+
471
+ /**
472
+ * Add work items to the "Work Done" section of a session note
473
+ * This is the main way to capture what was accomplished in a session
474
+ */
475
+ export function addWorkToSessionNote(notePath: string, workItems: WorkItem[], sectionTitle?: string): void {
476
+ if (!existsSync(notePath)) {
477
+ console.error(`Note file not found: ${notePath}`);
478
+ return;
479
+ }
480
+
481
+ let content = readFileSync(notePath, 'utf-8');
482
+
483
+ // Build the work section content
484
+ let workText = '';
485
+ if (sectionTitle) {
486
+ workText += `\n### ${sectionTitle}\n\n`;
487
+ }
488
+
489
+ for (const item of workItems) {
490
+ const checkbox = item.completed !== false ? '[x]' : '[ ]';
491
+ workText += `- ${checkbox} **${item.title}**\n`;
492
+ if (item.details && item.details.length > 0) {
493
+ for (const detail of item.details) {
494
+ workText += ` - ${detail}\n`;
495
+ }
496
+ }
497
+ }
498
+
499
+ // Find the Work Done section and insert after the comment/placeholder
500
+ const workDoneMatch = content.match(/## Work Done\n\n(<!-- .*? -->)?/);
501
+ if (workDoneMatch) {
502
+ const insertPoint = content.indexOf(workDoneMatch[0]) + workDoneMatch[0].length;
503
+ content = content.substring(0, insertPoint) + workText + content.substring(insertPoint);
504
+ } else {
505
+ // Fallback: insert before Next Steps
506
+ const nextStepsIndex = content.indexOf('## Next Steps');
507
+ if (nextStepsIndex !== -1) {
508
+ content = content.substring(0, nextStepsIndex) + workText + '\n' + content.substring(nextStepsIndex);
509
+ }
510
+ }
511
+
512
+ writeFileSync(notePath, content);
513
+ console.error(`Added ${workItems.length} work item(s) to: ${basename(notePath)}`);
514
+ }
515
+
516
+ /**
517
+ * Update the session note title to be more descriptive
518
+ * Called when we know what work was done
519
+ */
520
+ export function updateSessionNoteTitle(notePath: string, newTitle: string): void {
521
+ if (!existsSync(notePath)) {
522
+ console.error(`Note file not found: ${notePath}`);
523
+ return;
524
+ }
525
+
526
+ let content = readFileSync(notePath, 'utf-8');
527
+
528
+ // Update the H1 title
529
+ content = content.replace(/^# Session \d+:.*$/m, (match) => {
530
+ const sessionNum = match.match(/Session (\d+)/)?.[1] || '';
531
+ return `# Session ${sessionNum}: ${newTitle}`;
532
+ });
533
+
534
+ writeFileSync(notePath, content);
535
+
536
+ // Also rename the file
537
+ renameSessionNote(notePath, sanitizeForFilename(newTitle));
538
+ }
539
+
540
+ /**
541
+ * Sanitize a string for use in a filename (exported for use elsewhere)
542
+ */
543
+ export function sanitizeForFilename(str: string): string {
544
+ return str
545
+ .toLowerCase()
546
+ .replace(/[^a-z0-9\s-]/g, '') // Remove special chars
547
+ .replace(/\s+/g, '-') // Spaces to hyphens
548
+ .replace(/-+/g, '-') // Collapse multiple hyphens
549
+ .replace(/^-|-$/g, '') // Trim hyphens
550
+ .substring(0, 50); // Limit length
551
+ }
552
+
553
+ /**
554
+ * Extract a meaningful name from session note content
555
+ * Looks at Work Done section and summary to generate a descriptive name
556
+ */
557
+ export function extractMeaningfulName(noteContent: string, summary: string): string {
558
+ // Try to extract from Work Done section headers (### headings)
559
+ const workDoneMatch = noteContent.match(/## Work Done\n\n([\s\S]*?)(?=\n---|\n## Next)/);
560
+
561
+ if (workDoneMatch) {
562
+ const workDoneSection = workDoneMatch[1];
563
+
564
+ // Look for ### subheadings which typically describe what was done
565
+ const subheadings = workDoneSection.match(/### ([^\n]+)/g);
566
+ if (subheadings && subheadings.length > 0) {
567
+ // Use the first subheading, clean it up
568
+ const firstHeading = subheadings[0].replace('### ', '').trim();
569
+ if (firstHeading.length > 5 && firstHeading.length < 60) {
570
+ return sanitizeForFilename(firstHeading);
571
+ }
572
+ }
573
+
574
+ // Look for bold text which often indicates key topics
575
+ const boldMatches = workDoneSection.match(/\*\*([^*]+)\*\*/g);
576
+ if (boldMatches && boldMatches.length > 0) {
577
+ const firstBold = boldMatches[0].replace(/\*\*/g, '').trim();
578
+ if (firstBold.length > 3 && firstBold.length < 50) {
579
+ return sanitizeForFilename(firstBold);
580
+ }
581
+ }
582
+
583
+ // Look for numbered list items (1. Something)
584
+ const numberedItems = workDoneSection.match(/^\d+\.\s+\*\*([^*]+)\*\*/m);
585
+ if (numberedItems) {
586
+ return sanitizeForFilename(numberedItems[1]);
587
+ }
588
+ }
589
+
590
+ // Fall back to summary if provided
591
+ if (summary && summary.length > 5 && summary !== 'Session completed.') {
592
+ // Take first meaningful phrase from summary
593
+ const cleanSummary = summary
594
+ .replace(/[^\w\s-]/g, ' ')
595
+ .trim()
596
+ .split(/\s+/)
597
+ .slice(0, 5)
598
+ .join(' ');
599
+
600
+ if (cleanSummary.length > 3) {
601
+ return sanitizeForFilename(cleanSummary);
602
+ }
603
+ }
604
+
605
+ return '';
606
+ }
607
+
608
+ /**
609
+ * Rename session note with a meaningful name
610
+ * ALWAYS uses correct format: "NNNN - YYYY-MM-DD - Description.md"
611
+ * Returns the new path, or original path if rename fails
612
+ */
613
+ export function renameSessionNote(notePath: string, meaningfulName: string): string {
614
+ if (!meaningfulName || !existsSync(notePath)) {
615
+ return notePath;
616
+ }
617
+
618
+ const dir = join(notePath, '..');
619
+ const oldFilename = basename(notePath);
620
+
621
+ // Parse existing filename - support multiple formats:
622
+ // CORRECT: "0001 - 2026-01-02 - Description.md"
623
+ // Legacy: "001_2026-01-02_description.md"
624
+ const correctMatch = oldFilename.match(/^(\d{3,4}) - (\d{4}-\d{2}-\d{2}) - .*\.md$/);
625
+ const legacyMatch = oldFilename.match(/^(\d{3,4})_(\d{4}-\d{2}-\d{2})_.*\.md$/);
626
+
627
+ const match = correctMatch || legacyMatch;
628
+ if (!match) {
629
+ return notePath; // Can't parse, don't rename
630
+ }
631
+
632
+ const [, noteNumber, date] = match;
633
+
634
+ // Convert to Title Case
635
+ const titleCaseName = meaningfulName
636
+ .split(/[\s_-]+/)
637
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
638
+ .join(' ')
639
+ .trim();
640
+
641
+ // ALWAYS use correct format with 4-digit number
642
+ const paddedNumber = noteNumber.padStart(4, '0');
643
+ const newFilename = `${paddedNumber} - ${date} - ${titleCaseName}.md`;
644
+ const newPath = join(dir, newFilename);
645
+
646
+ // Don't rename if name is the same
647
+ if (newFilename === oldFilename) {
648
+ return notePath;
649
+ }
650
+
651
+ try {
652
+ renameSync(notePath, newPath);
653
+ console.error(`Renamed note: ${oldFilename} → ${newFilename}`);
654
+ return newPath;
655
+ } catch (error) {
656
+ console.error(`Could not rename note: ${error}`);
657
+ return notePath;
658
+ }
659
+ }
660
+
661
+ /**
662
+ * Finalize session note (mark as complete, add summary, rename with meaningful name)
663
+ * IDEMPOTENT: Will only finalize once, subsequent calls are no-ops
664
+ * Returns the final path (may be renamed)
665
+ */
666
+ export function finalizeSessionNote(notePath: string, summary: string): string {
667
+ if (!existsSync(notePath)) {
668
+ console.error(`Note file not found: ${notePath}`);
669
+ return notePath;
670
+ }
671
+
672
+ let content = readFileSync(notePath, 'utf-8');
673
+
674
+ // IDEMPOTENT CHECK: If already completed, don't modify again
675
+ if (content.includes('**Status:** Completed')) {
676
+ console.error(`Note already finalized: ${basename(notePath)}`);
677
+ return notePath;
678
+ }
679
+
680
+ // Update status
681
+ content = content.replace('**Status:** In Progress', '**Status:** Completed');
682
+
683
+ // Add completion timestamp (only if not already present)
684
+ if (!content.includes('**Completed:**')) {
685
+ const completionTime = new Date().toISOString();
686
+ content = content.replace(
687
+ '---\n\n## Work Done',
688
+ `**Completed:** ${completionTime}\n\n---\n\n## Work Done`
689
+ );
690
+ }
691
+
692
+ // Add summary to Next Steps section (only if placeholder exists)
693
+ const nextStepsMatch = content.match(/## Next Steps\n\n(<!-- .*? -->)/);
694
+ if (nextStepsMatch) {
695
+ content = content.replace(
696
+ nextStepsMatch[0],
697
+ `## Next Steps\n\n${summary || 'Session completed.'}`
698
+ );
699
+ }
700
+
701
+ writeFileSync(notePath, content);
702
+ console.error(`Session note finalized: ${basename(notePath)}`);
703
+
704
+ // Extract meaningful name and rename the file
705
+ const meaningfulName = extractMeaningfulName(content, summary);
706
+ if (meaningfulName) {
707
+ const newPath = renameSessionNote(notePath, meaningfulName);
708
+ return newPath;
709
+ }
710
+
711
+ return notePath;
712
+ }
713
+
714
+ /**
715
+ * Calculate total tokens from a session .jsonl file
716
+ */
717
+ export function calculateSessionTokens(jsonlPath: string): number {
718
+ if (!existsSync(jsonlPath)) {
719
+ return 0;
720
+ }
721
+
722
+ try {
723
+ const content = readFileSync(jsonlPath, 'utf-8');
724
+ const lines = content.trim().split('\n');
725
+ let totalTokens = 0;
726
+
727
+ for (const line of lines) {
728
+ try {
729
+ const entry = JSON.parse(line);
730
+ if (entry.message?.usage) {
731
+ const usage = entry.message.usage;
732
+ totalTokens += (usage.input_tokens || 0);
733
+ totalTokens += (usage.output_tokens || 0);
734
+ totalTokens += (usage.cache_creation_input_tokens || 0);
735
+ totalTokens += (usage.cache_read_input_tokens || 0);
736
+ }
737
+ } catch {
738
+ // Skip invalid JSON lines
739
+ }
740
+ }
741
+
742
+ return totalTokens;
743
+ } catch (error) {
744
+ console.error(`Error calculating tokens: ${error}`);
745
+ return 0;
746
+ }
747
+ }
748
+
749
+ /**
750
+ * Find TODO.md - check local first, fallback to central
751
+ */
752
+ export function findTodoPath(cwd: string): string {
753
+ // Check local locations first
754
+ const localPaths = [
755
+ join(cwd, 'TODO.md'),
756
+ join(cwd, 'notes', 'TODO.md'),
757
+ join(cwd, 'Notes', 'TODO.md'),
758
+ join(cwd, '.claude', 'TODO.md')
759
+ ];
760
+
761
+ for (const path of localPaths) {
762
+ if (existsSync(path)) {
763
+ return path;
764
+ }
765
+ }
766
+
767
+ // Fallback to central location (inside Notes/)
768
+ return join(getNotesDir(cwd), 'TODO.md');
769
+ }
770
+
771
+ /**
772
+ * Find CLAUDE.md - check local locations
773
+ * Returns the FIRST found path (for backwards compatibility)
774
+ */
775
+ export function findClaudeMdPath(cwd: string): string | null {
776
+ const paths = findAllClaudeMdPaths(cwd);
777
+ return paths.length > 0 ? paths[0] : null;
778
+ }
779
+
780
+ /**
781
+ * Find ALL CLAUDE.md files in local locations
782
+ * Returns paths in priority order (most specific first):
783
+ * 1. .claude/CLAUDE.md (project-specific config dir)
784
+ * 2. CLAUDE.md (project root)
785
+ * 3. Notes/CLAUDE.md (notes directory)
786
+ * 4. Prompts/CLAUDE.md (prompts directory)
787
+ *
788
+ * All found files will be loaded and injected into context.
789
+ */
790
+ export function findAllClaudeMdPaths(cwd: string): string[] {
791
+ const foundPaths: string[] = [];
792
+
793
+ // Priority order: most specific first
794
+ const localPaths = [
795
+ join(cwd, '.claude', 'CLAUDE.md'),
796
+ join(cwd, 'CLAUDE.md'),
797
+ join(cwd, 'Notes', 'CLAUDE.md'),
798
+ join(cwd, 'notes', 'CLAUDE.md'),
799
+ join(cwd, 'Prompts', 'CLAUDE.md'),
800
+ join(cwd, 'prompts', 'CLAUDE.md')
801
+ ];
802
+
803
+ for (const path of localPaths) {
804
+ if (existsSync(path)) {
805
+ foundPaths.push(path);
806
+ }
807
+ }
808
+
809
+ return foundPaths;
810
+ }
811
+
812
+ /**
813
+ * Ensure TODO.md exists
814
+ */
815
+ export function ensureTodoMd(cwd: string): string {
816
+ const todoPath = findTodoPath(cwd);
817
+
818
+ if (!existsSync(todoPath)) {
819
+ // Ensure parent directory exists
820
+ const parentDir = join(todoPath, '..');
821
+ if (!existsSync(parentDir)) {
822
+ mkdirSync(parentDir, { recursive: true });
823
+ }
824
+
825
+ const content = `# TODO
826
+
827
+ ## Current Session
828
+
829
+ - [ ] (Tasks will be tracked here)
830
+
831
+ ## Backlog
832
+
833
+ - [ ] (Future tasks)
834
+
835
+ ---
836
+
837
+ *Last updated: ${new Date().toISOString()}*
838
+ `;
839
+
840
+ writeFileSync(todoPath, content);
841
+ console.error(`Created TODO.md: ${todoPath}`);
842
+ }
843
+
844
+ return todoPath;
845
+ }
846
+
847
+ /**
848
+ * Task item for TODO.md
849
+ */
850
+ export interface TodoItem {
851
+ content: string;
852
+ completed: boolean;
853
+ }
854
+
855
+ /**
856
+ * Update TODO.md with current session tasks
857
+ * Preserves the Backlog section
858
+ * Ensures only ONE timestamp line at the end
859
+ */
860
+ export function updateTodoMd(cwd: string, tasks: TodoItem[], sessionSummary?: string): void {
861
+ const todoPath = ensureTodoMd(cwd);
862
+ const content = readFileSync(todoPath, 'utf-8');
863
+
864
+ // Find Backlog section to preserve it (but strip any trailing timestamps/separators)
865
+ const backlogMatch = content.match(/## Backlog[\s\S]*?(?=\n---|\n\*Last updated|$)/);
866
+ let backlogSection = backlogMatch ? backlogMatch[0].trim() : '## Backlog\n\n- [ ] (Future tasks)';
867
+
868
+ // Format tasks
869
+ const taskLines = tasks.length > 0
870
+ ? tasks.map(t => `- [${t.completed ? 'x' : ' '}] ${t.content}`).join('\n')
871
+ : '- [ ] (No active tasks)';
872
+
873
+ // Build new content with exactly ONE timestamp at the end
874
+ const newContent = `# TODO
875
+
876
+ ## Current Session
877
+
878
+ ${taskLines}
879
+
880
+ ${sessionSummary ? `**Session Summary:** ${sessionSummary}\n\n` : ''}${backlogSection}
881
+
882
+ ---
883
+
884
+ *Last updated: ${new Date().toISOString()}*
885
+ `;
886
+
887
+ writeFileSync(todoPath, newContent);
888
+ console.error(`Updated TODO.md: ${todoPath}`);
889
+ }
890
+
891
+ /**
892
+ * Add a checkpoint entry to TODO.md (without replacing tasks)
893
+ * Ensures only ONE timestamp line at the end
894
+ */
895
+ export function addTodoCheckpoint(cwd: string, checkpoint: string): void {
896
+ const todoPath = ensureTodoMd(cwd);
897
+ let content = readFileSync(todoPath, 'utf-8');
898
+
899
+ // Remove ALL existing timestamp lines and trailing separators
900
+ content = content.replace(/(\n---\s*)*(\n\*Last updated:.*\*\s*)+$/g, '');
901
+
902
+ // Add checkpoint before Backlog section
903
+ const backlogIndex = content.indexOf('## Backlog');
904
+ if (backlogIndex !== -1) {
905
+ const checkpointText = `\n**Checkpoint (${new Date().toISOString()}):** ${checkpoint}\n\n`;
906
+ content = content.substring(0, backlogIndex) + checkpointText + content.substring(backlogIndex);
907
+ }
908
+
909
+ // Add exactly ONE timestamp at the end
910
+ content = content.trimEnd() + `\n\n---\n\n*Last updated: ${new Date().toISOString()}*\n`;
911
+
912
+ writeFileSync(todoPath, content);
913
+ console.error(`Checkpoint added to TODO.md`);
914
+ }