@tekmidian/pai 0.7.1 → 0.7.3

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.
@@ -16,12 +16,15 @@
16
16
  import { existsSync, readFileSync, writeFileSync } from 'fs';
17
17
  import { basename, dirname, join } from 'path';
18
18
  import { tmpdir } from 'os';
19
+ import { connect } from 'net';
20
+ import { randomUUID } from 'crypto';
19
21
  import {
20
22
  sendNtfyNotification,
21
23
  getCurrentNotePath,
22
24
  createSessionNote,
23
25
  appendCheckpoint,
24
26
  addWorkToSessionNote,
27
+ isMeaningfulTitle,
25
28
  findNotesDir,
26
29
  renameSessionNote,
27
30
  updateTodoContinue,
@@ -39,6 +42,9 @@ interface HookInput {
39
42
  trigger?: string;
40
43
  }
41
44
 
45
+ const DAEMON_SOCKET = process.env.PAI_SOCKET ?? '/tmp/pai.sock';
46
+ const DAEMON_TIMEOUT_MS = 3_000;
47
+
42
48
  /** Structured data extracted from a transcript in a single pass. */
43
49
  interface TranscriptData {
44
50
  userMessages: string[];
@@ -233,50 +239,63 @@ function formatSessionState(data: TranscriptData, cwd?: string): string | null {
233
239
  // ---------------------------------------------------------------------------
234
240
 
235
241
  function deriveTitle(data: TranscriptData): string {
236
- let title = '';
242
+ // Collect candidates in priority order, then pick the first meaningful one.
243
+ const candidates: string[] = [];
237
244
 
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;
245
+ // 1. Work item titles (most descriptive of what was accomplished)
246
+ for (let i = data.workItems.length - 1; i >= 0; i--) {
247
+ candidates.push(data.workItems[i].title);
241
248
  }
242
- // 2. Last summary
243
- else if (data.summaries.length > 0) {
244
- title = data.summaries[data.summaries.length - 1];
249
+
250
+ // 2. Summaries
251
+ for (let i = data.summaries.length - 1; i >= 0; i--) {
252
+ candidates.push(data.summaries[i]);
245
253
  }
254
+
246
255
  // 3. Last completed marker
247
- else if (data.lastCompleted && data.lastCompleted.length > 5) {
248
- title = data.lastCompleted;
256
+ if (data.lastCompleted && data.lastCompleted.length > 5) {
257
+ candidates.push(data.lastCompleted);
249
258
  }
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
- }
259
+
260
+ // 4. User messages (FIRST meaningful one, not last — first is more likely
261
+ // to describe the session's purpose; last is often system noise)
262
+ for (const msg of data.userMessages) {
263
+ const line = msg.split('\n')[0].trim();
264
+ if (line.length > 10 && line.length < 80 &&
265
+ !line.toLowerCase().startsWith('yes') &&
266
+ !line.toLowerCase().startsWith('ok')) {
267
+ candidates.push(line);
260
268
  }
261
269
  }
262
- // 5. Derive from files modified
263
- if (!title && data.filesModified.length > 0) {
270
+
271
+ // 5. Derive from files modified (fallback)
272
+ if (data.filesModified.length > 0) {
264
273
  const basenames = data.filesModified.slice(-5).map(f => {
265
274
  const b = basename(f);
266
275
  return b.replace(/\.[^.]+$/, '');
267
276
  });
268
277
  const unique = [...new Set(basenames)];
269
- title = unique.length <= 3
270
- ? `Updated ${unique.join(', ')}`
271
- : `Modified ${data.filesModified.length} files`;
278
+ candidates.push(
279
+ unique.length <= 3
280
+ ? `Updated ${unique.join(', ')}`
281
+ : `Modified ${data.filesModified.length} files`
282
+ );
283
+ }
284
+
285
+ // Pick the first candidate that passes the meaningfulness filter
286
+ for (const raw of candidates) {
287
+ const cleaned = raw
288
+ .replace(/[^\w\s-]/g, ' ') // Remove special chars
289
+ .replace(/\s+/g, ' ') // Normalize whitespace
290
+ .trim()
291
+ .substring(0, 60);
292
+ if (cleaned.length >= 5 && isMeaningfulTitle(cleaned)) {
293
+ return cleaned;
294
+ }
272
295
  }
273
296
 
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);
297
+ // All candidates were garbage — return empty (caller will not rename)
298
+ return '';
280
299
  }
281
300
 
282
301
  // ---------------------------------------------------------------------------
@@ -338,6 +357,85 @@ function saveCumulativeState(notesDir: string, data: TranscriptData, notePath: s
338
357
  }
339
358
  }
340
359
 
360
+ // ---------------------------------------------------------------------------
361
+ // Daemon IPC — enqueue session-summary work item
362
+ // ---------------------------------------------------------------------------
363
+
364
+ /**
365
+ * Send a session-summary work item to the daemon via IPC.
366
+ * Returns true on success, false if the daemon is unreachable.
367
+ * Times out after DAEMON_TIMEOUT_MS to avoid blocking the hook.
368
+ */
369
+ function enqueueSessionSummary(payload: {
370
+ cwd: string;
371
+ sessionId?: string;
372
+ transcriptPath?: string;
373
+ }): Promise<boolean> {
374
+ return new Promise((resolve) => {
375
+ let done = false;
376
+ let buffer = '';
377
+ let timer: ReturnType<typeof setTimeout> | null = null;
378
+
379
+ function finish(ok: boolean): void {
380
+ if (done) return;
381
+ done = true;
382
+ if (timer !== null) { clearTimeout(timer); timer = null; }
383
+ try { client.destroy(); } catch { /* ignore */ }
384
+ resolve(ok);
385
+ }
386
+
387
+ const client = connect(DAEMON_SOCKET, () => {
388
+ const msg = JSON.stringify({
389
+ id: randomUUID(),
390
+ method: 'work_queue_enqueue',
391
+ params: {
392
+ type: 'session-summary',
393
+ priority: 4, // lower priority than session-end (2)
394
+ payload: {
395
+ cwd: payload.cwd,
396
+ sessionId: payload.sessionId,
397
+ transcriptPath: payload.transcriptPath,
398
+ },
399
+ },
400
+ }) + '\n';
401
+ client.write(msg);
402
+ });
403
+
404
+ client.on('data', (chunk: Buffer) => {
405
+ buffer += chunk.toString();
406
+ const nl = buffer.indexOf('\n');
407
+ if (nl === -1) return;
408
+ const line = buffer.slice(0, nl);
409
+ try {
410
+ const response = JSON.parse(line) as { ok: boolean; error?: string; result?: { id: string } };
411
+ if (response.ok) {
412
+ console.error(`PRE-COMPACT: Session summary enqueued with daemon (id=${response.result?.id}).`);
413
+ finish(true);
414
+ } else {
415
+ console.error(`PRE-COMPACT: Daemon rejected session-summary: ${response.error}`);
416
+ finish(false);
417
+ }
418
+ } catch {
419
+ finish(false);
420
+ }
421
+ });
422
+
423
+ client.on('error', (e: NodeJS.ErrnoException) => {
424
+ if (e.code === 'ENOENT' || e.code === 'ECONNREFUSED') {
425
+ console.error('PRE-COMPACT: Daemon not running — skipping session summary.');
426
+ } else {
427
+ console.error(`PRE-COMPACT: Daemon socket error: ${e.message}`);
428
+ }
429
+ finish(false);
430
+ });
431
+
432
+ timer = setTimeout(() => {
433
+ console.error('PRE-COMPACT: Daemon IPC timed out — skipping session summary.');
434
+ finish(false);
435
+ }, DAEMON_TIMEOUT_MS);
436
+ });
437
+ }
438
+
341
439
  // ---------------------------------------------------------------------------
342
440
  // Main
343
441
  // ---------------------------------------------------------------------------
@@ -402,23 +500,32 @@ async function main() {
402
500
  }
403
501
 
404
502
  // -----------------------------------------------------------------
405
- // Persist session state to numbered session note (like "pause session")
503
+ // Persist session state to numbered session note.
504
+ // RULE: ONE note per session. NEVER create a new note during compaction.
505
+ // If the latest note is completed, reopen it (remove completed status)
506
+ // rather than creating a duplicate.
406
507
  // -----------------------------------------------------------------
407
508
  let notePath: string | null = null;
408
509
 
409
510
  try {
410
511
  notePath = getCurrentNotePath(notesInfo.path);
411
512
 
412
- // If no note found, or the latest note is completed, create a new one
413
513
  if (!notePath) {
514
+ // Truly no note exists at all — create one (first compaction of a session
515
+ // that started before PAI was installed, or corrupted notes dir)
414
516
  console.error('No session note found — creating one for checkpoint');
415
- notePath = createSessionNote(notesInfo.path, 'Recovered Session');
517
+ notePath = createSessionNote(notesInfo.path, 'Untitled Session');
416
518
  } else {
519
+ // If the latest note is completed, reopen it for this session's checkpoints
520
+ // instead of creating a duplicate. This handles the case where session-stop
521
+ // finalized a note but the session continued (e.g., user said "end session"
522
+ // but kept working).
417
523
  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');
524
+ let noteContent = readFileSync(notePath, 'utf-8');
525
+ if (noteContent.includes('**Status:** Completed')) {
526
+ noteContent = noteContent.replace('**Status:** Completed', '**Status:** In Progress');
527
+ writeFileSync(notePath, noteContent);
528
+ console.error(`Reopened completed note for continued session: ${basename(notePath)}`);
422
529
  }
423
530
  } catch { /* proceed with existing note */ }
424
531
  }
@@ -516,6 +623,21 @@ async function main() {
516
623
  }
517
624
  }
518
625
 
626
+ // -----------------------------------------------------------------------
627
+ // Enqueue session-summary work item with daemon for AI-powered note generation
628
+ // -----------------------------------------------------------------------
629
+ if (hookInput?.cwd) {
630
+ try {
631
+ await enqueueSessionSummary({
632
+ cwd: hookInput.cwd,
633
+ sessionId: hookInput.session_id,
634
+ transcriptPath: hookInput.transcript_path,
635
+ });
636
+ } catch (err) {
637
+ console.error(`Could not enqueue session-summary: ${err}`);
638
+ }
639
+ }
640
+
519
641
  // Send ntfy.sh notification
520
642
  const ntfyMessage = tokenCount > 0
521
643
  ? `Auto-pause: ~${Math.round(tokenCount / 1000)}k tokens`
@@ -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;
@@ -311,9 +370,48 @@ async function main() {
311
370
  };
312
371
 
313
372
  if (detected.error === 'no_match') {
314
- 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.
315
412
  Run "pai project add ." to register this project, or use /route to tag the session.`;
316
- console.error('PAI detect: no match for', cwd);
413
+ console.error('PAI detect: no match for', cwd);
414
+ }
317
415
  } else if (detected.slug) {
318
416
  const name = detected.display_name || detected.slug;
319
417
  const nameSlug = ` (slug: ${detected.slug})`;
@@ -146,6 +146,67 @@ function enqueueWithDaemon(payload: {
146
146
  });
147
147
  }
148
148
 
149
+ /**
150
+ * Enqueue a session-summary work item with the daemon for AI-powered note generation.
151
+ * Non-blocking — if daemon is unavailable, silently skips (the mechanical note is sufficient).
152
+ *
153
+ * Note: we intentionally omit transcriptPath here to let the worker call findLatestJsonl()
154
+ * itself. At session-end, Claude Code may still be moving the JSONL to sessions/, so a
155
+ * stale path passed from the hook could point to a file that no longer exists.
156
+ */
157
+ function enqueueSessionSummaryWithDaemon(payload: {
158
+ cwd: string;
159
+ }): Promise<boolean> {
160
+ return new Promise((resolve) => {
161
+ let done = false;
162
+ let buffer = '';
163
+ let timer: ReturnType<typeof setTimeout> | null = null;
164
+
165
+ function finish(ok: boolean): void {
166
+ if (done) return;
167
+ done = true;
168
+ if (timer !== null) { clearTimeout(timer); timer = null; }
169
+ try { client.destroy(); } catch { /* ignore */ }
170
+ resolve(ok);
171
+ }
172
+
173
+ const client = connect(DAEMON_SOCKET, () => {
174
+ const msg = JSON.stringify({
175
+ id: randomUUID(),
176
+ method: 'work_queue_enqueue',
177
+ params: {
178
+ type: 'session-summary',
179
+ priority: 4,
180
+ payload: {
181
+ cwd: payload.cwd,
182
+ force: true,
183
+ },
184
+ },
185
+ }) + '\n';
186
+ client.write(msg);
187
+ });
188
+
189
+ client.on('data', (chunk: Buffer) => {
190
+ buffer += chunk.toString();
191
+ const nl = buffer.indexOf('\n');
192
+ if (nl === -1) return;
193
+ const line = buffer.slice(0, nl);
194
+ try {
195
+ const response = JSON.parse(line) as { ok: boolean; result?: { id: string } };
196
+ if (response.ok) {
197
+ console.error(`STOP-HOOK: Session summary enqueued (id=${response.result?.id}).`);
198
+ }
199
+ } catch { /* ignore */ }
200
+ finish(true);
201
+ });
202
+
203
+ client.on('error', () => finish(false));
204
+ client.on('end', () => { if (!done) finish(false); });
205
+
206
+ timer = setTimeout(() => finish(false), DAEMON_TIMEOUT_MS);
207
+ });
208
+ }
209
+
149
210
  // ---------------------------------------------------------------------------
150
211
  // Direct execution — fallback path (original stop-hook logic)
151
212
  // ---------------------------------------------------------------------------
@@ -489,6 +550,11 @@ async function main() {
489
550
  await executeDirectly(lines, transcriptPath, cwd, message, lastUserQuery);
490
551
  }
491
552
 
553
+ // Also enqueue a session-summary for AI-powered note generation.
554
+ // We omit transcriptPath so the worker resolves it via findLatestJsonl(),
555
+ // avoiding a race where the session-end hook moves the JSONL before the worker reads it.
556
+ await enqueueSessionSummaryWithDaemon({ cwd });
557
+
492
558
  console.error(`STOP-HOOK COMPLETED SUCCESSFULLY at ${new Date().toISOString()}\n`);
493
559
  }
494
560