@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.
- package/ARCHITECTURE.md +16 -10
- package/README.md +46 -6
- package/dist/{auto-route-JjW3f7pV.mjs → auto-route-B5MSUJZK.mjs} +3 -3
- package/dist/{auto-route-JjW3f7pV.mjs.map → auto-route-B5MSUJZK.mjs.map} +1 -1
- package/dist/cli/index.mjs +313 -43
- package/dist/cli/index.mjs.map +1 -1
- package/dist/{config-DELNqq3Z.mjs → config-B4brrHHE.mjs} +1 -1
- package/dist/{config-DELNqq3Z.mjs.map → config-B4brrHHE.mjs.map} +1 -1
- package/dist/daemon/index.mjs +7 -7
- package/dist/daemon-mcp/index.mjs +11 -4
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/{daemon-CeTX4NpF.mjs → daemon-s868Paua.mjs} +12 -12
- package/dist/{daemon-CeTX4NpF.mjs.map → daemon-s868Paua.mjs.map} +1 -1
- package/dist/{detect-D7gPV3fQ.mjs → detect-CdaA48EI.mjs} +1 -1
- package/dist/{detect-D7gPV3fQ.mjs.map → detect-CdaA48EI.mjs.map} +1 -1
- package/dist/{detector-cYYhK2Mi.mjs → detector-Bp-2SM3x.mjs} +2 -2
- package/dist/{detector-cYYhK2Mi.mjs.map → detector-Bp-2SM3x.mjs.map} +1 -1
- package/dist/{factory-DZLvRf4m.mjs → factory-CeXQzlwn.mjs} +3 -3
- package/dist/{factory-DZLvRf4m.mjs.map → factory-CeXQzlwn.mjs.map} +1 -1
- package/dist/hooks/capture-all-events.mjs +238 -0
- package/dist/hooks/capture-all-events.mjs.map +7 -0
- package/dist/hooks/capture-session-summary.mjs +198 -0
- package/dist/hooks/capture-session-summary.mjs.map +7 -0
- package/dist/hooks/capture-tool-output.mjs +105 -0
- package/dist/hooks/capture-tool-output.mjs.map +7 -0
- package/dist/hooks/cleanup-session-files.mjs +129 -0
- package/dist/hooks/cleanup-session-files.mjs.map +7 -0
- package/dist/hooks/context-compression-hook.mjs +283 -0
- package/dist/hooks/context-compression-hook.mjs.map +7 -0
- package/dist/hooks/initialize-session.mjs +206 -0
- package/dist/hooks/initialize-session.mjs.map +7 -0
- package/dist/hooks/load-core-context.mjs +110 -0
- package/dist/hooks/load-core-context.mjs.map +7 -0
- package/dist/hooks/load-project-context.mjs +548 -0
- package/dist/hooks/load-project-context.mjs.map +7 -0
- package/dist/hooks/security-validator.mjs +159 -0
- package/dist/hooks/security-validator.mjs.map +7 -0
- package/dist/hooks/stop-hook.mjs +625 -0
- package/dist/hooks/stop-hook.mjs.map +7 -0
- package/dist/hooks/subagent-stop-hook.mjs +152 -0
- package/dist/hooks/subagent-stop-hook.mjs.map +7 -0
- package/dist/hooks/sync-todo-to-md.mjs +322 -0
- package/dist/hooks/sync-todo-to-md.mjs.map +7 -0
- package/dist/hooks/update-tab-on-action.mjs +90 -0
- package/dist/hooks/update-tab-on-action.mjs.map +7 -0
- package/dist/hooks/update-tab-titles.mjs +55 -0
- package/dist/hooks/update-tab-titles.mjs.map +7 -0
- package/dist/index.d.mts +29 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +4 -3
- package/dist/{indexer-backend-BHztlJJg.mjs → indexer-backend-DQO-FqAI.mjs} +1 -1
- package/dist/{indexer-backend-BHztlJJg.mjs.map → indexer-backend-DQO-FqAI.mjs.map} +1 -1
- package/dist/{ipc-client-CLt2fNlC.mjs → ipc-client-CgSpwHDC.mjs} +1 -1
- package/dist/{ipc-client-CLt2fNlC.mjs.map → ipc-client-CgSpwHDC.mjs.map} +1 -1
- package/dist/mcp/index.mjs +15 -5
- package/dist/mcp/index.mjs.map +1 -1
- package/dist/{postgres-CRBe30Ag.mjs → postgres-CIxeqf_n.mjs} +1 -1
- package/dist/{postgres-CRBe30Ag.mjs.map → postgres-CIxeqf_n.mjs.map} +1 -1
- package/dist/reranker-D7bRAHi6.mjs +71 -0
- package/dist/reranker-D7bRAHi6.mjs.map +1 -0
- package/dist/{schemas-BY3Pjvje.mjs → schemas-BFIgGntb.mjs} +1 -1
- package/dist/{schemas-BY3Pjvje.mjs.map → schemas-BFIgGntb.mjs.map} +1 -1
- package/dist/{search-GK0ibTJy.mjs → search-_oHfguA5.mjs} +47 -4
- package/dist/search-_oHfguA5.mjs.map +1 -0
- package/dist/{sqlite-RyR8Up1v.mjs → sqlite-CymLKiDE.mjs} +2 -2
- package/dist/{sqlite-RyR8Up1v.mjs.map → sqlite-CymLKiDE.mjs.map} +1 -1
- package/dist/{tools-CUg0Lyg-.mjs → tools-Dx7GjOHd.mjs} +23 -14
- package/dist/tools-Dx7GjOHd.mjs.map +1 -0
- package/dist/{vault-indexer-Bo2aPSzP.mjs → vault-indexer-DXWs9pDn.mjs} +1 -1
- package/dist/{vault-indexer-Bo2aPSzP.mjs.map → vault-indexer-DXWs9pDn.mjs.map} +1 -1
- package/dist/{zettelkasten-Co-w0XSZ.mjs → zettelkasten-e-a4rW_6.mjs} +2 -2
- package/dist/{zettelkasten-Co-w0XSZ.mjs.map → zettelkasten-e-a4rW_6.mjs.map} +1 -1
- package/package.json +4 -2
- package/scripts/build-hooks.mjs +51 -0
- package/src/hooks/ts/capture-all-events.ts +179 -0
- package/src/hooks/ts/lib/detect-environment.ts +53 -0
- package/src/hooks/ts/lib/metadata-extraction.ts +144 -0
- package/src/hooks/ts/lib/pai-paths.ts +124 -0
- package/src/hooks/ts/lib/project-utils.ts +914 -0
- package/src/hooks/ts/post-tool-use/capture-tool-output.ts +78 -0
- package/src/hooks/ts/post-tool-use/sync-todo-to-md.ts +230 -0
- package/src/hooks/ts/post-tool-use/update-tab-on-action.ts +145 -0
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +155 -0
- package/src/hooks/ts/pre-tool-use/security-validator.ts +258 -0
- package/src/hooks/ts/session-end/capture-session-summary.ts +185 -0
- package/src/hooks/ts/session-start/initialize-session.ts +155 -0
- package/src/hooks/ts/session-start/load-core-context.ts +104 -0
- package/src/hooks/ts/session-start/load-project-context.ts +394 -0
- package/src/hooks/ts/stop/stop-hook.ts +407 -0
- package/src/hooks/ts/subagent-stop/subagent-stop-hook.ts +212 -0
- package/src/hooks/ts/user-prompt/cleanup-session-files.ts +45 -0
- package/src/hooks/ts/user-prompt/update-tab-titles.ts +88 -0
- package/tab-color-command.sh +24 -0
- package/templates/skills/createskill-skill.template.md +78 -0
- package/templates/skills/history-system.template.md +371 -0
- package/templates/skills/hook-system.template.md +913 -0
- package/templates/skills/sessions-skill.template.md +102 -0
- package/templates/skills/skill-system.template.md +214 -0
- package/templates/skills/terminal-tabs.template.md +120 -0
- package/dist/search-GK0ibTJy.mjs.map +0 -1
- 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();
|