@tekmidian/pai 0.3.2 → 0.5.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 (101) hide show
  1. package/ARCHITECTURE.md +16 -10
  2. package/README.md +46 -6
  3. package/dist/{auto-route-JjW3f7pV.mjs → auto-route-B5MSUJZK.mjs} +3 -3
  4. package/dist/{auto-route-JjW3f7pV.mjs.map → auto-route-B5MSUJZK.mjs.map} +1 -1
  5. package/dist/cli/index.mjs +313 -43
  6. package/dist/cli/index.mjs.map +1 -1
  7. package/dist/{config-DELNqq3Z.mjs → config-B4brrHHE.mjs} +1 -1
  8. package/dist/{config-DELNqq3Z.mjs.map → config-B4brrHHE.mjs.map} +1 -1
  9. package/dist/daemon/index.mjs +7 -7
  10. package/dist/daemon-mcp/index.mjs +11 -4
  11. package/dist/daemon-mcp/index.mjs.map +1 -1
  12. package/dist/{daemon-CeTX4NpF.mjs → daemon-s868Paua.mjs} +12 -12
  13. package/dist/{daemon-CeTX4NpF.mjs.map → daemon-s868Paua.mjs.map} +1 -1
  14. package/dist/{detect-D7gPV3fQ.mjs → detect-CdaA48EI.mjs} +1 -1
  15. package/dist/{detect-D7gPV3fQ.mjs.map → detect-CdaA48EI.mjs.map} +1 -1
  16. package/dist/{detector-cYYhK2Mi.mjs → detector-Bp-2SM3x.mjs} +2 -2
  17. package/dist/{detector-cYYhK2Mi.mjs.map → detector-Bp-2SM3x.mjs.map} +1 -1
  18. package/dist/{factory-DZLvRf4m.mjs → factory-CeXQzlwn.mjs} +3 -3
  19. package/dist/{factory-DZLvRf4m.mjs.map → factory-CeXQzlwn.mjs.map} +1 -1
  20. package/dist/hooks/capture-all-events.mjs +238 -0
  21. package/dist/hooks/capture-all-events.mjs.map +7 -0
  22. package/dist/hooks/capture-session-summary.mjs +198 -0
  23. package/dist/hooks/capture-session-summary.mjs.map +7 -0
  24. package/dist/hooks/capture-tool-output.mjs +105 -0
  25. package/dist/hooks/capture-tool-output.mjs.map +7 -0
  26. package/dist/hooks/cleanup-session-files.mjs +129 -0
  27. package/dist/hooks/cleanup-session-files.mjs.map +7 -0
  28. package/dist/hooks/context-compression-hook.mjs +283 -0
  29. package/dist/hooks/context-compression-hook.mjs.map +7 -0
  30. package/dist/hooks/initialize-session.mjs +206 -0
  31. package/dist/hooks/initialize-session.mjs.map +7 -0
  32. package/dist/hooks/load-core-context.mjs +110 -0
  33. package/dist/hooks/load-core-context.mjs.map +7 -0
  34. package/dist/hooks/load-project-context.mjs +548 -0
  35. package/dist/hooks/load-project-context.mjs.map +7 -0
  36. package/dist/hooks/security-validator.mjs +159 -0
  37. package/dist/hooks/security-validator.mjs.map +7 -0
  38. package/dist/hooks/stop-hook.mjs +625 -0
  39. package/dist/hooks/stop-hook.mjs.map +7 -0
  40. package/dist/hooks/subagent-stop-hook.mjs +152 -0
  41. package/dist/hooks/subagent-stop-hook.mjs.map +7 -0
  42. package/dist/hooks/sync-todo-to-md.mjs +322 -0
  43. package/dist/hooks/sync-todo-to-md.mjs.map +7 -0
  44. package/dist/hooks/update-tab-on-action.mjs +90 -0
  45. package/dist/hooks/update-tab-on-action.mjs.map +7 -0
  46. package/dist/hooks/update-tab-titles.mjs +55 -0
  47. package/dist/hooks/update-tab-titles.mjs.map +7 -0
  48. package/dist/index.d.mts +29 -1
  49. package/dist/index.d.mts.map +1 -1
  50. package/dist/index.mjs +4 -3
  51. package/dist/{indexer-backend-BHztlJJg.mjs → indexer-backend-DQO-FqAI.mjs} +1 -1
  52. package/dist/{indexer-backend-BHztlJJg.mjs.map → indexer-backend-DQO-FqAI.mjs.map} +1 -1
  53. package/dist/{ipc-client-CLt2fNlC.mjs → ipc-client-CgSpwHDC.mjs} +1 -1
  54. package/dist/{ipc-client-CLt2fNlC.mjs.map → ipc-client-CgSpwHDC.mjs.map} +1 -1
  55. package/dist/mcp/index.mjs +15 -5
  56. package/dist/mcp/index.mjs.map +1 -1
  57. package/dist/{postgres-CRBe30Ag.mjs → postgres-CIxeqf_n.mjs} +1 -1
  58. package/dist/{postgres-CRBe30Ag.mjs.map → postgres-CIxeqf_n.mjs.map} +1 -1
  59. package/dist/reranker-D7bRAHi6.mjs +71 -0
  60. package/dist/reranker-D7bRAHi6.mjs.map +1 -0
  61. package/dist/{schemas-BY3Pjvje.mjs → schemas-BFIgGntb.mjs} +1 -1
  62. package/dist/{schemas-BY3Pjvje.mjs.map → schemas-BFIgGntb.mjs.map} +1 -1
  63. package/dist/{search-GK0ibTJy.mjs → search-_oHfguA5.mjs} +47 -4
  64. package/dist/search-_oHfguA5.mjs.map +1 -0
  65. package/dist/{sqlite-RyR8Up1v.mjs → sqlite-CymLKiDE.mjs} +2 -2
  66. package/dist/{sqlite-RyR8Up1v.mjs.map → sqlite-CymLKiDE.mjs.map} +1 -1
  67. package/dist/{tools-CUg0Lyg-.mjs → tools-Dx7GjOHd.mjs} +23 -14
  68. package/dist/tools-Dx7GjOHd.mjs.map +1 -0
  69. package/dist/{vault-indexer-Bo2aPSzP.mjs → vault-indexer-DXWs9pDn.mjs} +1 -1
  70. package/dist/{vault-indexer-Bo2aPSzP.mjs.map → vault-indexer-DXWs9pDn.mjs.map} +1 -1
  71. package/dist/{zettelkasten-Co-w0XSZ.mjs → zettelkasten-e-a4rW_6.mjs} +2 -2
  72. package/dist/{zettelkasten-Co-w0XSZ.mjs.map → zettelkasten-e-a4rW_6.mjs.map} +1 -1
  73. package/package.json +4 -2
  74. package/scripts/build-hooks.mjs +51 -0
  75. package/src/hooks/ts/capture-all-events.ts +179 -0
  76. package/src/hooks/ts/lib/detect-environment.ts +53 -0
  77. package/src/hooks/ts/lib/metadata-extraction.ts +144 -0
  78. package/src/hooks/ts/lib/pai-paths.ts +124 -0
  79. package/src/hooks/ts/lib/project-utils.ts +914 -0
  80. package/src/hooks/ts/post-tool-use/capture-tool-output.ts +78 -0
  81. package/src/hooks/ts/post-tool-use/sync-todo-to-md.ts +230 -0
  82. package/src/hooks/ts/post-tool-use/update-tab-on-action.ts +145 -0
  83. package/src/hooks/ts/pre-compact/context-compression-hook.ts +155 -0
  84. package/src/hooks/ts/pre-tool-use/security-validator.ts +258 -0
  85. package/src/hooks/ts/session-end/capture-session-summary.ts +185 -0
  86. package/src/hooks/ts/session-start/initialize-session.ts +155 -0
  87. package/src/hooks/ts/session-start/load-core-context.ts +104 -0
  88. package/src/hooks/ts/session-start/load-project-context.ts +394 -0
  89. package/src/hooks/ts/stop/stop-hook.ts +407 -0
  90. package/src/hooks/ts/subagent-stop/subagent-stop-hook.ts +212 -0
  91. package/src/hooks/ts/user-prompt/cleanup-session-files.ts +45 -0
  92. package/src/hooks/ts/user-prompt/update-tab-titles.ts +88 -0
  93. package/tab-color-command.sh +24 -0
  94. package/templates/skills/createskill-skill.template.md +78 -0
  95. package/templates/skills/history-system.template.md +371 -0
  96. package/templates/skills/hook-system.template.md +913 -0
  97. package/templates/skills/sessions-skill.template.md +102 -0
  98. package/templates/skills/skill-system.template.md +214 -0
  99. package/templates/skills/terminal-tabs.template.md +120 -0
  100. package/dist/search-GK0ibTJy.mjs.map +0 -1
  101. package/dist/tools-CUg0Lyg-.mjs.map +0 -1
@@ -0,0 +1,407 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from 'fs';
4
+ import { join, basename, dirname } from 'path';
5
+ import {
6
+ sendNtfyNotification,
7
+ getCurrentNotePath,
8
+ finalizeSessionNote,
9
+ moveSessionFilesToSessionsDir,
10
+ addWorkToSessionNote,
11
+ findNotesDir,
12
+ WorkItem
13
+ } from '../lib/project-utils';
14
+
15
+ /**
16
+ * Extract work items from transcript for session note
17
+ * Looks for SUMMARY, ACTIONS, RESULTS sections in assistant responses
18
+ */
19
+ function extractWorkFromTranscript(lines: string[]): WorkItem[] {
20
+ const workItems: WorkItem[] = [];
21
+ const seenSummaries = new Set<string>();
22
+
23
+ // Process all assistant messages to find work summaries
24
+ for (const line of lines) {
25
+ try {
26
+ const entry = JSON.parse(line);
27
+ if (entry.type === 'assistant' && entry.message?.content) {
28
+ const content = contentToText(entry.message.content);
29
+
30
+ // Look for SUMMARY: lines (our standard format)
31
+ const summaryMatch = content.match(/SUMMARY:\s*(.+?)(?:\n|$)/i);
32
+ if (summaryMatch) {
33
+ const summary = summaryMatch[1].trim();
34
+ if (summary && !seenSummaries.has(summary) && summary.length > 5) {
35
+ seenSummaries.add(summary);
36
+
37
+ // Try to extract details from ACTIONS or RESULTS
38
+ const details: string[] = [];
39
+
40
+ const actionsMatch = content.match(/ACTIONS:\s*(.+?)(?=\n[A-Z]+:|$)/is);
41
+ if (actionsMatch) {
42
+ // Extract bullet points or numbered items
43
+ const actionLines = actionsMatch[1].split('\n')
44
+ .map(l => l.replace(/^[-*•]\s*/, '').replace(/^\d+\.\s*/, '').trim())
45
+ .filter(l => l.length > 3 && l.length < 100);
46
+ details.push(...actionLines.slice(0, 3)); // Max 3 action items
47
+ }
48
+
49
+ workItems.push({
50
+ title: summary,
51
+ details: details.length > 0 ? details : undefined,
52
+ completed: true
53
+ });
54
+ }
55
+ }
56
+
57
+ // Also look for COMPLETED: lines as backup
58
+ const completedMatch = content.match(/COMPLETED:\s*(.+?)(?:\n|$)/i);
59
+ if (completedMatch && workItems.length === 0) {
60
+ const completed = completedMatch[1].trim().replace(/\*+/g, '');
61
+ if (completed && !seenSummaries.has(completed) && completed.length > 5) {
62
+ seenSummaries.add(completed);
63
+ workItems.push({
64
+ title: completed,
65
+ completed: true
66
+ });
67
+ }
68
+ }
69
+ }
70
+ } catch {
71
+ // Skip invalid JSON lines
72
+ }
73
+ }
74
+
75
+ return workItems;
76
+ }
77
+
78
+ /**
79
+ * Generate 4-word tab title summarizing what was done
80
+ */
81
+ function generateTabTitle(prompt: string, completedLine?: string): string {
82
+ // If we have a completed line, try to use it for a better summary
83
+ if (completedLine) {
84
+ const cleanCompleted = completedLine
85
+ .replace(/\*+/g, '')
86
+ .replace(/\[.*?\]/g, '')
87
+ .replace(/COMPLETED:\s*/gi, '')
88
+ .trim();
89
+
90
+ // Extract meaningful words from the completed line
91
+ const completedWords = cleanCompleted.split(/\s+/)
92
+ .filter(word => word.length > 2 &&
93
+ !['the', 'and', 'but', 'for', 'are', 'with', 'his', 'her', 'this', 'that', 'you', 'can', 'will', 'have', 'been', 'your', 'from', 'they', 'were', 'said', 'what', 'them', 'just', 'told', 'how', 'does', 'into', 'about', 'completed'].includes(word.toLowerCase()))
94
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
95
+
96
+ if (completedWords.length >= 2) {
97
+ // Build a 4-word summary from completed line
98
+ const summary = completedWords.slice(0, 4);
99
+ while (summary.length < 4) {
100
+ summary.push('Done');
101
+ }
102
+ return summary.slice(0, 4).join(' ');
103
+ }
104
+ }
105
+
106
+ // Fall back to parsing the prompt
107
+ const cleanPrompt = prompt.replace(/[^\w\s]/g, ' ').trim();
108
+ const words = cleanPrompt.split(/\s+/).filter(word =>
109
+ word.length > 2 &&
110
+ !['the', 'and', 'but', 'for', 'are', 'with', 'his', 'her', 'this', 'that', 'you', 'can', 'will', 'have', 'been', 'your', 'from', 'they', 'were', 'said', 'what', 'them', 'just', 'told', 'how', 'does', 'into', 'about'].includes(word.toLowerCase())
111
+ );
112
+
113
+ const lowerPrompt = prompt.toLowerCase();
114
+
115
+ // Find action verb if present
116
+ const actionVerbs = ['test', 'rename', 'fix', 'debug', 'research', 'write', 'create', 'make', 'build', 'implement', 'analyze', 'review', 'update', 'modify', 'generate', 'develop', 'design', 'deploy', 'configure', 'setup', 'install', 'remove', 'delete', 'add', 'check', 'verify', 'validate', 'optimize', 'refactor', 'enhance', 'improve', 'send', 'email', 'help', 'updated', 'fixed', 'created', 'built', 'added'];
117
+
118
+ let titleWords: string[] = [];
119
+
120
+ // Check for action verb
121
+ for (const verb of actionVerbs) {
122
+ if (lowerPrompt.includes(verb)) {
123
+ // Convert to past tense for summary
124
+ let pastTense = verb;
125
+ if (verb === 'write') pastTense = 'Wrote';
126
+ else if (verb === 'make') pastTense = 'Made';
127
+ else if (verb === 'send') pastTense = 'Sent';
128
+ else if (verb.endsWith('e')) pastTense = verb.charAt(0).toUpperCase() + verb.slice(1, -1) + 'ed';
129
+ else pastTense = verb.charAt(0).toUpperCase() + verb.slice(1) + 'ed';
130
+
131
+ titleWords.push(pastTense);
132
+ break;
133
+ }
134
+ }
135
+
136
+ // Add most meaningful remaining words
137
+ const remainingWords = words
138
+ .filter(word => !actionVerbs.includes(word.toLowerCase()))
139
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
140
+
141
+ // Fill up to 4 words total
142
+ for (const word of remainingWords) {
143
+ if (titleWords.length < 4) {
144
+ titleWords.push(word);
145
+ } else {
146
+ break;
147
+ }
148
+ }
149
+
150
+ // If we don't have enough words, add generic ones
151
+ if (titleWords.length === 0) {
152
+ titleWords.push('Completed');
153
+ }
154
+ if (titleWords.length === 1) {
155
+ titleWords.push('Task');
156
+ }
157
+ if (titleWords.length === 2) {
158
+ titleWords.push('Successfully');
159
+ }
160
+ if (titleWords.length === 3) {
161
+ titleWords.push('Done');
162
+ }
163
+
164
+ return titleWords.slice(0, 4).join(' ');
165
+ }
166
+
167
+ /**
168
+ * Set terminal tab title (works with Kitty, Ghostty, iTerm2, etc.)
169
+ */
170
+ function setTerminalTabTitle(title: string): void {
171
+ const term = process.env.TERM || '';
172
+
173
+ if (term.includes('ghostty')) {
174
+ process.stderr.write(`\x1b]2;${title}\x07`);
175
+ process.stderr.write(`\x1b]0;${title}\x07`);
176
+ process.stderr.write(`\x1b]7;${title}\x07`);
177
+ process.stderr.write(`\x1b]2;${title}\x1b\\`);
178
+ } else if (term.includes('kitty')) {
179
+ process.stderr.write(`\x1b]0;${title}\x07`);
180
+ process.stderr.write(`\x1b]2;${title}\x07`);
181
+ process.stderr.write(`\x1b]30;${title}\x07`);
182
+ } else {
183
+ process.stderr.write(`\x1b]0;${title}\x07`);
184
+ process.stderr.write(`\x1b]2;${title}\x07`);
185
+ }
186
+
187
+ if (process.stderr.isTTY) {
188
+ process.stderr.write('');
189
+ }
190
+ }
191
+
192
+ // Helper to safely turn Claude content (string or array of blocks) into plain text
193
+ function contentToText(content: any): string {
194
+ if (typeof content === 'string') return content;
195
+ if (Array.isArray(content)) {
196
+ return content
197
+ .map((c) => {
198
+ if (typeof c === 'string') return c;
199
+ if (c?.text) return c.text;
200
+ if (c?.content) return String(c.content);
201
+ return '';
202
+ })
203
+ .join(' ')
204
+ .trim();
205
+ }
206
+ return '';
207
+ }
208
+
209
+ async function main() {
210
+ const timestamp = new Date().toISOString();
211
+ console.error(`\nSTOP-HOOK TRIGGERED AT ${timestamp}`);
212
+
213
+ // Get input
214
+ let input = '';
215
+ const decoder = new TextDecoder();
216
+
217
+ try {
218
+ for await (const chunk of process.stdin) {
219
+ input += decoder.decode(chunk, { stream: true });
220
+ }
221
+ } catch (e) {
222
+ console.error(`Error reading input: ${e}`);
223
+ process.exit(0);
224
+ }
225
+
226
+ if (!input) {
227
+ console.error('No input received');
228
+ process.exit(0);
229
+ }
230
+
231
+ let transcriptPath: string;
232
+ let cwd: string;
233
+ try {
234
+ const parsed = JSON.parse(input);
235
+ transcriptPath = parsed.transcript_path;
236
+ cwd = parsed.cwd || process.cwd();
237
+ console.error(`Transcript path: ${transcriptPath}`);
238
+ console.error(`Working directory: ${cwd}`);
239
+ } catch (e) {
240
+ console.error(`Error parsing input JSON: ${e}`);
241
+ process.exit(0);
242
+ }
243
+
244
+ if (!transcriptPath) {
245
+ console.error('No transcript_path in input');
246
+ process.exit(0);
247
+ }
248
+
249
+ // Read the transcript
250
+ let transcript;
251
+ try {
252
+ transcript = readFileSync(transcriptPath, 'utf-8');
253
+ console.error(`Transcript loaded: ${transcript.split('\n').length} lines`);
254
+ } catch (e) {
255
+ console.error(`Error reading transcript: ${e}`);
256
+ process.exit(0);
257
+ }
258
+
259
+ // Parse the JSON lines to find what happened in this session
260
+ const lines = transcript.trim().split('\n');
261
+
262
+ // Get the last user query for context
263
+ let lastUserQuery = '';
264
+ for (let i = lines.length - 1; i >= 0; i--) {
265
+ try {
266
+ const entry = JSON.parse(lines[i]);
267
+ if (entry.type === 'user' && entry.message?.content) {
268
+ const content = entry.message.content;
269
+ if (typeof content === 'string') {
270
+ lastUserQuery = content;
271
+ } else if (Array.isArray(content)) {
272
+ for (const item of content) {
273
+ if (item.type === 'text' && item.text) {
274
+ lastUserQuery = item.text;
275
+ break;
276
+ }
277
+ }
278
+ }
279
+ if (lastUserQuery) break;
280
+ }
281
+ } catch (e) {
282
+ // Skip invalid JSON
283
+ }
284
+ }
285
+
286
+ // Extract the completion message from the last assistant response
287
+ let message = '';
288
+
289
+ const lastResponse = lines[lines.length - 1];
290
+ try {
291
+ const entry = JSON.parse(lastResponse);
292
+ if (entry.type === 'assistant' && entry.message?.content) {
293
+ const content = contentToText(entry.message.content);
294
+
295
+ // Look for COMPLETED line
296
+ const completedMatch = content.match(/COMPLETED:\s*(.+?)(?:\n|$)/i);
297
+ if (completedMatch) {
298
+ message = completedMatch[1].trim()
299
+ .replace(/\*+/g, '')
300
+ .replace(/\[.*?\]/g, '')
301
+ .trim();
302
+ console.error(`COMPLETION: ${message}`);
303
+ }
304
+ }
305
+ } catch (e) {
306
+ console.error('Error parsing assistant response:', e);
307
+ }
308
+
309
+ // Set tab title
310
+ let tabTitle = message || '';
311
+
312
+ if (!tabTitle && lastUserQuery) {
313
+ try {
314
+ const entry = JSON.parse(lastResponse);
315
+ if (entry.type === 'assistant' && entry.message?.content) {
316
+ const content = contentToText(entry.message.content);
317
+ const completedMatch = content.match(/COMPLETED:\s*(.+?)(?:\n|$)/im);
318
+ if (completedMatch) {
319
+ tabTitle = completedMatch[1].trim()
320
+ .replace(/\*+/g, '')
321
+ .replace(/\[.*?\]/g, '')
322
+ .trim();
323
+ }
324
+ }
325
+ } catch (e) {}
326
+
327
+ if (!tabTitle) {
328
+ tabTitle = generateTabTitle(lastUserQuery, '');
329
+ }
330
+ }
331
+
332
+ if (tabTitle) {
333
+ try {
334
+ const escapedTitle = tabTitle.replace(/'/g, "'\\''");
335
+ const { execSync } = await import('child_process');
336
+ execSync(`printf '\\033]0;${escapedTitle}\\007' >&2`);
337
+ execSync(`printf '\\033]2;${escapedTitle}\\007' >&2`);
338
+ execSync(`printf '\\033]30;${escapedTitle}\\007' >&2`);
339
+ console.error(`Tab title set to: "${tabTitle}"`);
340
+ } catch (e) {
341
+ console.error(`Failed to set tab title: ${e}`);
342
+ }
343
+ }
344
+
345
+ console.error(`User query: ${lastUserQuery || 'No query found'}`);
346
+ console.error(`Message: ${message || 'No completion message'}`);
347
+
348
+ // Final tab title override as the very last action
349
+ if (message) {
350
+ const finalTabTitle = message.slice(0, 50);
351
+ process.stderr.write(`\x1b]2;${finalTabTitle}\x07`);
352
+ }
353
+
354
+ // Send ntfy.sh notification
355
+ if (message) {
356
+ await sendNtfyNotification(message);
357
+ } else {
358
+ await sendNtfyNotification('Session ended');
359
+ }
360
+
361
+ // Finalize session note if one exists
362
+ try {
363
+ const notesInfo = findNotesDir(cwd);
364
+ console.error(`Notes directory: ${notesInfo.path} (${notesInfo.isLocal ? 'local' : 'central'})`);
365
+ const currentNotePath = getCurrentNotePath(notesInfo.path);
366
+
367
+ if (currentNotePath) {
368
+ // FIRST: Extract and add work items from transcript
369
+ const workItems = extractWorkFromTranscript(lines);
370
+ if (workItems.length > 0) {
371
+ addWorkToSessionNote(currentNotePath, workItems);
372
+ console.error(`Added ${workItems.length} work item(s) to session note`);
373
+ } else {
374
+ // If no structured work items found, at least add the completion message
375
+ if (message) {
376
+ addWorkToSessionNote(currentNotePath, [{
377
+ title: message,
378
+ completed: true
379
+ }]);
380
+ console.error(`Added completion message to session note`);
381
+ }
382
+ }
383
+
384
+ // THEN: Finalize the note
385
+ const summary = message || 'Session completed.';
386
+ finalizeSessionNote(currentNotePath, summary);
387
+ console.error(`Session note finalized: ${basename(currentNotePath)}`);
388
+ }
389
+ } catch (noteError) {
390
+ console.error(`Could not finalize session note: ${noteError}`);
391
+ }
392
+
393
+ // Move all session .jsonl files to sessions/ subdirectory
394
+ try {
395
+ const transcriptDir = dirname(transcriptPath);
396
+ const movedCount = moveSessionFilesToSessionsDir(transcriptDir);
397
+ if (movedCount > 0) {
398
+ console.error(`Moved ${movedCount} session file(s) to sessions/`);
399
+ }
400
+ } catch (moveError) {
401
+ console.error(`Could not move session files: ${moveError}`);
402
+ }
403
+
404
+ console.error(`STOP-HOOK COMPLETED SUCCESSFULLY at ${new Date().toISOString()}\n`);
405
+ }
406
+
407
+ main().catch(() => {});
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync, existsSync } from 'fs';
4
+
5
+ async function delay(ms: number): Promise<void> {
6
+ return new Promise(resolve => setTimeout(resolve, ms));
7
+ }
8
+
9
+ async function findTaskResult(transcriptPath: string, maxAttempts: number = 10): Promise<{ result: string | null, agentType: string | null }> {
10
+ console.error(`Looking for Task result in transcript: ${transcriptPath}`);
11
+
12
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
13
+ if (attempt > 0) {
14
+ // Wait progressively longer between attempts
15
+ await delay(100 * attempt);
16
+ }
17
+
18
+ if (!existsSync(transcriptPath)) {
19
+ console.error(`Transcript file doesn't exist yet (attempt ${attempt + 1}/${maxAttempts})`);
20
+ continue;
21
+ }
22
+
23
+ try {
24
+ const transcript = readFileSync(transcriptPath, 'utf-8');
25
+ const lines = transcript.trim().split('\n');
26
+
27
+ // Search from the end of the transcript backwards
28
+ for (let i = lines.length - 1; i >= 0; i--) {
29
+ try {
30
+ const entry = JSON.parse(lines[i]);
31
+
32
+ // Look for assistant messages that contain Task tool_use
33
+ if (entry.type === 'assistant' && entry.message?.content) {
34
+ for (const content of entry.message.content) {
35
+ if (content.type === 'tool_use' && content.name === 'Task') {
36
+ console.error(`Found Task invocation with subagent: ${content.input?.subagent_type}`);
37
+ // Found a Task invocation, now look for its result
38
+ // The result should be in a subsequent user message
39
+ for (let j = i + 1; j < lines.length; j++) {
40
+ const resultEntry = JSON.parse(lines[j]);
41
+ if (resultEntry.type === 'user' && resultEntry.message?.content) {
42
+ for (const resultContent of resultEntry.message.content) {
43
+ if (resultContent.type === 'tool_result' && resultContent.tool_use_id === content.id) {
44
+ // Found the matching Task result
45
+ const taskOutput = resultContent.content;
46
+
47
+ // Extract agent type from the output
48
+ let agentType = 'default';
49
+ const agentMatch = taskOutput.match(/Sub-agent\s+(\w+)\s+completed/i);
50
+ if (agentMatch) {
51
+ agentType = agentMatch[1].toLowerCase();
52
+ }
53
+
54
+ return { result: taskOutput, agentType };
55
+ }
56
+ }
57
+ }
58
+ }
59
+ }
60
+ }
61
+ }
62
+ } catch (e) {
63
+ // Invalid JSON line, skip
64
+ }
65
+ }
66
+ } catch (e) {
67
+ // Error reading file, will retry
68
+ }
69
+ }
70
+
71
+ return { result: null, agentType: null };
72
+ }
73
+
74
+ function extractCompletionMessage(taskOutput: string): { message: string | null, agentType: string | null } {
75
+ // Look for the COMPLETED section in the agent's output
76
+ // Priority is given to [AGENT:type] format
77
+ const agentPatterns = [
78
+ // Handle markdown formatting with asterisks
79
+ /\*+COMPLETED:\*+\s*\[AGENT:(\w+)\]\s*I\s+completed\s+(.+?)(?:\n|$)/is,
80
+ // Non-markdown patterns
81
+ /COMPLETED:\s*\[AGENT:(\w+)\]\s*I\s+completed\s+(.+?)(?:\n|$)/is,
82
+ /\[AGENT:(\w+)\]\s*I\s+completed\s+(.+?)(?:\.|!|\n|$)/is,
83
+ ];
84
+
85
+ // First try to match agent-specific patterns
86
+ for (const pattern of agentPatterns) {
87
+ const match = taskOutput.match(pattern);
88
+ if (match && match[1] && match[2]) {
89
+ const agentType = match[1].toLowerCase();
90
+ let message = match[2].trim();
91
+
92
+ // Clean up the message
93
+ message = message.replace(/\*+/g, '');
94
+ message = message.replace(/\s+/g, ' ');
95
+
96
+ // Prepend agent name for spoken message
97
+ const agentName = agentType.charAt(0).toUpperCase() + agentType.slice(1);
98
+ const fullMessage = `${agentName} completed ${message}`;
99
+
100
+ console.error(`FOUND AGENT MATCH: [${agentType}] ${fullMessage}`);
101
+
102
+ return { message: fullMessage, agentType };
103
+ }
104
+ }
105
+
106
+ // Fall back to generic patterns but try to extract agent type
107
+ const genericPatterns = [
108
+ // Handle markdown formatting
109
+ /\*+COMPLETED:\*+\s*(.+?)(?:\n|$)/i,
110
+ // Non-markdown patterns
111
+ /COMPLETED:\s*(.+?)(?:\n|$)/i,
112
+ /Sub-agent\s+\w+\s+completed\s+(.+?)(?:\.|!|\n|$)/i,
113
+ /Agent\s+completed\s+(.+?)(?:\.|!|\n|$)/i
114
+ ];
115
+
116
+ for (const pattern of genericPatterns) {
117
+ const match = taskOutput.match(pattern);
118
+ if (match && match[1]) {
119
+ let message = match[1].trim();
120
+
121
+ // Clean up the message
122
+ message = message.replace(/^(the\s+)?requested\s+task$/i, '');
123
+ message = message.replace(/\*+/g, '');
124
+ message = message.replace(/\s+/g, ' ');
125
+
126
+ // Only return if it's not a generic message
127
+ if (message &&
128
+ !message.match(/^(the\s+)?requested\s+task$/i) &&
129
+ !message.match(/^task$/i) &&
130
+ message.length > 5) {
131
+
132
+ // Try to detect agent type from context
133
+ let agentType = null;
134
+ const agentMatch = taskOutput.match(/Sub-agent\s+(\w+)\s+completed/i);
135
+ if (agentMatch) {
136
+ agentType = agentMatch[1].toLowerCase();
137
+ }
138
+
139
+ return { message, agentType };
140
+ }
141
+ }
142
+ }
143
+
144
+ return { message: null, agentType: null };
145
+ }
146
+
147
+ async function main() {
148
+ console.error('SubagentStop hook started');
149
+ // Read input from stdin with timeout
150
+ let input = '';
151
+ try {
152
+ const decoder = new TextDecoder();
153
+
154
+ const timeoutPromise = new Promise<void>((resolve) => {
155
+ setTimeout(() => resolve(), 500);
156
+ });
157
+
158
+ const readPromise = (async () => {
159
+ for await (const chunk of process.stdin) {
160
+ input += decoder.decode(chunk, { stream: true });
161
+ }
162
+ })();
163
+
164
+ await Promise.race([readPromise, timeoutPromise]);
165
+ } catch (e) {
166
+ console.error('Failed to read input:', e);
167
+ process.exit(0);
168
+ }
169
+
170
+ if (!input) {
171
+ console.log('No input received');
172
+ process.exit(0);
173
+ }
174
+
175
+ let transcriptPath: string;
176
+ try {
177
+ const parsed = JSON.parse(input);
178
+ transcriptPath = parsed.transcript_path;
179
+ } catch (e) {
180
+ console.error('Invalid input JSON:', e);
181
+ process.exit(0);
182
+ }
183
+
184
+ if (!transcriptPath) {
185
+ console.log('No transcript path provided');
186
+ process.exit(0);
187
+ }
188
+
189
+ // Wait for and find the Task result
190
+ const { result: taskOutput, agentType } = await findTaskResult(transcriptPath);
191
+
192
+ if (!taskOutput) {
193
+ console.log('No Task result found in transcript after waiting');
194
+ process.exit(0);
195
+ }
196
+
197
+ // Extract the completion message and agent type
198
+ const { message: completionMessage, agentType: extractedAgentType } = extractCompletionMessage(taskOutput);
199
+
200
+ if (!completionMessage) {
201
+ console.log('No specific completion message found in Task output');
202
+ process.exit(0);
203
+ }
204
+
205
+ // Use extracted agent type if available, otherwise use the one from task analysis
206
+ const finalAgentType = extractedAgentType || agentType || 'default';
207
+ const agentName = finalAgentType.charAt(0).toUpperCase() + finalAgentType.slice(1);
208
+
209
+ console.log(`[${agentName}] ${completionMessage}`);
210
+ }
211
+
212
+ main().catch(console.error);
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cleanup-session-files.ts
4
+ *
5
+ * UserPromptSubmit hook that moves stray .jsonl files to sessions/ subdirectory.
6
+ * This catches files from previous sessions that didn't exit cleanly.
7
+ *
8
+ * Runs on every user prompt - lightweight check, only moves files if needed.
9
+ */
10
+
11
+ import { dirname, basename } from 'path';
12
+ import { moveSessionFilesToSessionsDir } from '../lib/project-utils';
13
+
14
+ interface HookInput {
15
+ session_id: string;
16
+ transcript_path: string;
17
+ }
18
+
19
+ async function main() {
20
+ try {
21
+ const chunks: Buffer[] = [];
22
+ for await (const chunk of process.stdin) {
23
+ chunks.push(chunk);
24
+ }
25
+ const input = Buffer.concat(chunks).toString('utf-8');
26
+ if (!input.trim()) return;
27
+
28
+ const data: HookInput = JSON.parse(input);
29
+ if (!data.transcript_path) return;
30
+
31
+ const projectDir = dirname(data.transcript_path);
32
+ const currentSessionFile = basename(data.transcript_path);
33
+
34
+ // Move stray .jsonl files, excluding the current active session (silent mode)
35
+ const movedCount = moveSessionFilesToSessionsDir(projectDir, currentSessionFile, true);
36
+
37
+ if (movedCount > 0) {
38
+ console.error(`Cleaned up ${movedCount} session file(s) to sessions/`);
39
+ }
40
+ } catch {
41
+ // Silent failure - don't block user prompts
42
+ }
43
+ }
44
+
45
+ main();