@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.
- package/dist/cli/index.mjs +1 -1
- package/dist/daemon/index.mjs +1 -1
- package/dist/{daemon-D3hYb5_C.mjs → daemon-DuGlDnV7.mjs} +861 -4
- package/dist/daemon-DuGlDnV7.mjs.map +1 -0
- package/dist/hooks/context-compression-hook.mjs +58 -22
- package/dist/hooks/context-compression-hook.mjs.map +2 -2
- package/dist/hooks/load-project-context.mjs +78 -27
- package/dist/hooks/load-project-context.mjs.map +3 -3
- package/dist/hooks/stop-hook.mjs +220 -125
- package/dist/hooks/stop-hook.mjs.map +3 -3
- package/dist/hooks/sync-todo-to-md.mjs.map +1 -1
- package/dist/skills/Reconstruct/SKILL.md +232 -0
- package/package.json +1 -1
- package/plugins/productivity/plugin.json +1 -1
- package/plugins/productivity/skills/Reconstruct/SKILL.md +232 -0
- package/src/hooks/ts/lib/project-utils/index.ts +1 -0
- package/src/hooks/ts/lib/project-utils/session-notes.ts +46 -5
- package/src/hooks/ts/lib/project-utils.ts +1 -0
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +60 -37
- package/src/hooks/ts/session-start/load-project-context.ts +110 -28
- package/src/hooks/ts/stop/stop-hook.ts +259 -199
- package/dist/daemon-D3hYb5_C.mjs.map +0 -1
|
@@ -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
|
-
|
|
237
|
+
// Collect candidates in priority order, then pick the first meaningful one.
|
|
238
|
+
const candidates: string[] = [];
|
|
237
239
|
|
|
238
|
-
// 1.
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
248
|
-
|
|
251
|
+
if (data.lastCompleted && data.lastCompleted.length > 5) {
|
|
252
|
+
candidates.push(data.lastCompleted);
|
|
249
253
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
273
|
+
candidates.push(
|
|
274
|
+
unique.length <= 3
|
|
275
|
+
? `Updated ${unique.join(', ')}`
|
|
276
|
+
: `Modified ${data.filesModified.length} files`
|
|
277
|
+
);
|
|
272
278
|
}
|
|
273
279
|
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
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, '
|
|
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
|
-
|
|
419
|
-
if (noteContent.includes('**Status:** Completed')
|
|
420
|
-
|
|
421
|
-
notePath
|
|
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
|
-
//
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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})`;
|