@tekmidian/pai 0.7.0 → 0.7.2

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.
@@ -22,6 +22,7 @@ import {
22
22
  createSessionNote,
23
23
  appendCheckpoint,
24
24
  addWorkToSessionNote,
25
+ isMeaningfulTitle,
25
26
  findNotesDir,
26
27
  renameSessionNote,
27
28
  updateTodoContinue,
@@ -233,50 +234,63 @@ function formatSessionState(data: TranscriptData, cwd?: string): string | null {
233
234
  // ---------------------------------------------------------------------------
234
235
 
235
236
  function deriveTitle(data: TranscriptData): string {
236
- let title = '';
237
+ // Collect candidates in priority order, then pick the first meaningful one.
238
+ const candidates: string[] = [];
237
239
 
238
- // 1. Last work item title (most descriptive of what was accomplished)
239
- if (data.workItems.length > 0) {
240
- title = data.workItems[data.workItems.length - 1].title;
240
+ // 1. Work item titles (most descriptive of what was accomplished)
241
+ for (let i = data.workItems.length - 1; i >= 0; i--) {
242
+ candidates.push(data.workItems[i].title);
241
243
  }
242
- // 2. Last summary
243
- else if (data.summaries.length > 0) {
244
- title = data.summaries[data.summaries.length - 1];
244
+
245
+ // 2. Summaries
246
+ for (let i = data.summaries.length - 1; i >= 0; i--) {
247
+ candidates.push(data.summaries[i]);
245
248
  }
249
+
246
250
  // 3. Last completed marker
247
- else if (data.lastCompleted && data.lastCompleted.length > 5) {
248
- title = data.lastCompleted;
251
+ if (data.lastCompleted && data.lastCompleted.length > 5) {
252
+ candidates.push(data.lastCompleted);
249
253
  }
250
- // 4. Last substantive user message
251
- else if (data.userMessages.length > 0) {
252
- for (let i = data.userMessages.length - 1; i >= 0; i--) {
253
- const msg = data.userMessages[i].split('\n')[0].trim();
254
- if (msg.length > 10 && msg.length < 80 &&
255
- !msg.toLowerCase().startsWith('yes') &&
256
- !msg.toLowerCase().startsWith('ok')) {
257
- title = msg;
258
- break;
259
- }
254
+
255
+ // 4. User messages (FIRST meaningful one, not last — first is more likely
256
+ // to describe the session's purpose; last is often system noise)
257
+ for (const msg of data.userMessages) {
258
+ const line = msg.split('\n')[0].trim();
259
+ if (line.length > 10 && line.length < 80 &&
260
+ !line.toLowerCase().startsWith('yes') &&
261
+ !line.toLowerCase().startsWith('ok')) {
262
+ candidates.push(line);
260
263
  }
261
264
  }
262
- // 5. Derive from files modified
263
- if (!title && data.filesModified.length > 0) {
265
+
266
+ // 5. Derive from files modified (fallback)
267
+ if (data.filesModified.length > 0) {
264
268
  const basenames = data.filesModified.slice(-5).map(f => {
265
269
  const b = basename(f);
266
270
  return b.replace(/\.[^.]+$/, '');
267
271
  });
268
272
  const unique = [...new Set(basenames)];
269
- title = unique.length <= 3
270
- ? `Updated ${unique.join(', ')}`
271
- : `Modified ${data.filesModified.length} files`;
273
+ candidates.push(
274
+ unique.length <= 3
275
+ ? `Updated ${unique.join(', ')}`
276
+ : `Modified ${data.filesModified.length} files`
277
+ );
272
278
  }
273
279
 
274
- // Clean up for filename use
275
- return title
276
- .replace(/[^\w\s-]/g, ' ') // Remove special chars
277
- .replace(/\s+/g, ' ') // Normalize whitespace
278
- .trim()
279
- .substring(0, 60);
280
+ // Pick the first candidate that passes the meaningfulness filter
281
+ for (const raw of candidates) {
282
+ const cleaned = raw
283
+ .replace(/[^\w\s-]/g, ' ') // Remove special chars
284
+ .replace(/\s+/g, ' ') // Normalize whitespace
285
+ .trim()
286
+ .substring(0, 60);
287
+ if (cleaned.length >= 5 && isMeaningfulTitle(cleaned)) {
288
+ return cleaned;
289
+ }
290
+ }
291
+
292
+ // All candidates were garbage — return empty (caller will not rename)
293
+ return '';
280
294
  }
281
295
 
282
296
  // ---------------------------------------------------------------------------
@@ -402,23 +416,32 @@ async function main() {
402
416
  }
403
417
 
404
418
  // -----------------------------------------------------------------
405
- // Persist session state to numbered session note (like "pause session")
419
+ // Persist session state to numbered session note.
420
+ // RULE: ONE note per session. NEVER create a new note during compaction.
421
+ // If the latest note is completed, reopen it (remove completed status)
422
+ // rather than creating a duplicate.
406
423
  // -----------------------------------------------------------------
407
424
  let notePath: string | null = null;
408
425
 
409
426
  try {
410
427
  notePath = getCurrentNotePath(notesInfo.path);
411
428
 
412
- // If no note found, or the latest note is completed, create a new one
413
429
  if (!notePath) {
430
+ // Truly no note exists at all — create one (first compaction of a session
431
+ // that started before PAI was installed, or corrupted notes dir)
414
432
  console.error('No session note found — creating one for checkpoint');
415
- notePath = createSessionNote(notesInfo.path, 'Recovered Session');
433
+ notePath = createSessionNote(notesInfo.path, 'Untitled Session');
416
434
  } else {
435
+ // If the latest note is completed, reopen it for this session's checkpoints
436
+ // instead of creating a duplicate. This handles the case where session-stop
437
+ // finalized a note but the session continued (e.g., user said "end session"
438
+ // but kept working).
417
439
  try {
418
- const noteContent = readFileSync(notePath, 'utf-8');
419
- if (noteContent.includes('**Status:** Completed') || noteContent.includes('**Completed:**')) {
420
- console.error(`Latest note is completed (${basename(notePath)}) creating new one`);
421
- notePath = createSessionNote(notesInfo.path, 'Continued Session');
440
+ let noteContent = readFileSync(notePath, 'utf-8');
441
+ if (noteContent.includes('**Status:** Completed')) {
442
+ noteContent = noteContent.replace('**Status:** Completed', '**Status:** In Progress');
443
+ writeFileSync(notePath, noteContent);
444
+ console.error(`Reopened completed note for continued session: ${basename(notePath)}`);
422
445
  }
423
446
  } catch { /* proceed with existing note */ }
424
447
  }
@@ -17,7 +17,8 @@
17
17
  */
18
18
 
19
19
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
20
- import { join, basename, dirname } from 'path';
20
+ import { join, basename, dirname, resolve } from 'path';
21
+ import { homedir } from 'os';
21
22
  import { execSync } from 'child_process';
22
23
  import {
23
24
  PAI_DIR,
@@ -72,6 +73,64 @@ function getRoutedNotesPath(): string | null {
72
73
  return null;
73
74
  }
74
75
 
76
+ /**
77
+ * Project signals that indicate a directory is a real project root.
78
+ */
79
+ const PROJECT_SIGNALS = [
80
+ '.git',
81
+ 'package.json',
82
+ 'pubspec.yaml',
83
+ 'Cargo.toml',
84
+ 'go.mod',
85
+ 'pyproject.toml',
86
+ 'setup.py',
87
+ 'build.gradle',
88
+ 'pom.xml',
89
+ 'composer.json',
90
+ 'Gemfile',
91
+ 'Makefile',
92
+ 'CMakeLists.txt',
93
+ 'tsconfig.json',
94
+ 'CLAUDE.md',
95
+ join('Notes', 'PAI.md'),
96
+ ];
97
+
98
+ /**
99
+ * Returns true if the given directory looks like a project root.
100
+ * Checks for the presence of well-known project signal files/dirs.
101
+ */
102
+ function hasProjectSignals(dir: string): boolean {
103
+ for (const signal of PROJECT_SIGNALS) {
104
+ if (existsSync(join(dir, signal))) return true;
105
+ }
106
+ return false;
107
+ }
108
+
109
+ /**
110
+ * Returns true if the directory should NOT be auto-registered.
111
+ * Guards: home directory, shallow paths, temp directories.
112
+ */
113
+ function isGuardedPath(dir: string): boolean {
114
+ const home = homedir();
115
+ const resolved = resolve(dir);
116
+
117
+ // Never register the home directory itself
118
+ if (resolved === home) return true;
119
+
120
+ // Depth guard: require at least 3 path segments beyond root
121
+ // e.g. /Users/i052341/foo is depth 3 on macOS — reject it
122
+ const parts = resolved.split('/').filter(Boolean);
123
+ if (parts.length < 3) return true;
124
+
125
+ // Temp/system directories
126
+ const forbidden = ['/tmp', '/var', '/private/tmp', '/private/var/folders'];
127
+ for (const prefix of forbidden) {
128
+ if (resolved === prefix || resolved.startsWith(prefix + '/')) return true;
129
+ }
130
+
131
+ return false;
132
+ }
133
+
75
134
  interface HookInput {
76
135
  session_id: string;
77
136
  cwd: string;
@@ -239,36 +298,20 @@ async function main() {
239
298
  if (notesDir) { // notesDir is always set now (local or central)
240
299
  const currentNotePath = getCurrentNotePath(notesDir);
241
300
 
242
- // Determine if we need a new note
243
- let needsNewNote = false;
301
+ // Only create a new note if there is truly no note at all.
302
+ // A completed note is still used — it will be updated or continued.
303
+ // This prevents duplicate notes at month boundaries and on every compaction.
244
304
  if (!currentNotePath) {
245
- needsNewNote = true;
305
+ // Defensive: ensure projectName is a usable string
306
+ const safeProjectName = (typeof projectName === 'string' && projectName.trim().length > 0)
307
+ ? projectName.trim()
308
+ : 'Untitled Session';
246
309
  console.error('\nNo previous session notes found - creating new one');
247
- } else {
248
- // Check if the existing note is completed
249
- try {
250
- const content = readFileSync(currentNotePath, 'utf-8');
251
- if (content.includes('**Status:** Completed') || content.includes('**Completed:**')) {
252
- needsNewNote = true;
253
- console.error(`\nPrevious note completed - creating new one`);
254
- const summaryMatch = content.match(/## Next Steps\n\n([^\n]+)/);
255
- if (summaryMatch) {
256
- console.error(` Previous: ${summaryMatch[1].substring(0, 60)}...`);
257
- }
258
- } else {
259
- console.error(`\nContinuing session note: ${basename(currentNotePath)}`);
260
- }
261
- } catch {
262
- needsNewNote = true;
263
- }
264
- }
265
-
266
- // Create new note if needed
267
- if (needsNewNote) {
268
- activeNotePath = createSessionNote(notesDir, projectName);
310
+ activeNotePath = createSessionNote(notesDir, String(safeProjectName));
269
311
  console.error(`Created: ${basename(activeNotePath)}`);
270
312
  } else {
271
313
  activeNotePath = currentNotePath!;
314
+ console.error(`\nUsing existing session note: ${basename(activeNotePath)}`);
272
315
  // Show preview of current note
273
316
  try {
274
317
  const content = readFileSync(activeNotePath, 'utf-8');
@@ -327,9 +370,48 @@ async function main() {
327
370
  };
328
371
 
329
372
  if (detected.error === 'no_match') {
330
- paiProjectBlock = `PAI Project Registry: No registered project matches this directory.
373
+ // Attempt auto-registration if the directory looks like a real project
374
+ let autoRegistered = false;
375
+
376
+ if (!isGuardedPath(cwd) && hasProjectSignals(cwd)) {
377
+ try {
378
+ execFileSync(paiBin, ['project', 'add', cwd], {
379
+ encoding: 'utf-8',
380
+ env: process.env,
381
+ });
382
+ console.error(`PAI auto-registered project at: ${cwd}`);
383
+
384
+ // Re-run detect to get the proper detection result
385
+ try {
386
+ const raw2 = execFileSync(paiBin, ['project', 'detect', '--json', cwd], {
387
+ encoding: 'utf-8',
388
+ env: process.env,
389
+ }).trim();
390
+
391
+ if (raw2) {
392
+ const detected2 = JSON.parse(raw2) as typeof detected;
393
+ if (detected2.slug) {
394
+ const name2 = detected2.display_name || detected2.slug;
395
+ console.error(`PAI auto-registered: "${detected2.slug}" (${detected2.match_type})`);
396
+ paiProjectBlock = `PAI Project Registry: ${name2} (slug: ${detected2.slug}) [AUTO-REGISTERED]
397
+ Match: ${detected2.match_type ?? 'exact'} | Sessions: 0`;
398
+ autoRegistered = true;
399
+ }
400
+ }
401
+ } catch (detectErr) {
402
+ console.error('PAI auto-registration: project added but re-detect failed:', detectErr);
403
+ autoRegistered = true; // project IS registered, just can't load context
404
+ }
405
+ } catch (addErr) {
406
+ console.error('PAI auto-registration failed (project add):', addErr);
407
+ }
408
+ }
409
+
410
+ if (!autoRegistered) {
411
+ paiProjectBlock = `PAI Project Registry: No registered project matches this directory.
331
412
  Run "pai project add ." to register this project, or use /route to tag the session.`;
332
- console.error('PAI detect: no match for', cwd);
413
+ console.error('PAI detect: no match for', cwd);
414
+ }
333
415
  } else if (detected.slug) {
334
416
  const name = detected.display_name || detected.slug;
335
417
  const nameSlug = ` (slug: ${detected.slug})`;