@tekmidian/pai 0.3.2 → 0.4.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/dist/cli/index.mjs +279 -21
- package/dist/cli/index.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/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
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PostToolUse Hook - Captures tool outputs for UOCS
|
|
5
|
+
*
|
|
6
|
+
* Automatically logs all tool executions to daily JSONL files
|
|
7
|
+
* for later processing and analysis.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { appendFileSync, mkdirSync, existsSync } from 'fs';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
import { PAI_DIR, HISTORY_DIR } from '../lib/pai-paths';
|
|
13
|
+
|
|
14
|
+
interface ToolUseData {
|
|
15
|
+
tool_name: string;
|
|
16
|
+
tool_input: Record<string, any>;
|
|
17
|
+
tool_response: Record<string, any>;
|
|
18
|
+
conversation_id: string;
|
|
19
|
+
timestamp: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Configuration
|
|
23
|
+
const CAPTURE_DIR = join(HISTORY_DIR, 'raw-outputs');
|
|
24
|
+
const INTERESTING_TOOLS = ['Bash', 'Edit', 'Write', 'Read', 'Task', 'NotebookEdit'];
|
|
25
|
+
|
|
26
|
+
async function main() {
|
|
27
|
+
try {
|
|
28
|
+
// Read input from stdin
|
|
29
|
+
const chunks: Buffer[] = [];
|
|
30
|
+
for await (const chunk of process.stdin) {
|
|
31
|
+
chunks.push(chunk);
|
|
32
|
+
}
|
|
33
|
+
const input = Buffer.concat(chunks).toString('utf-8');
|
|
34
|
+
if (!input || input.trim() === '') {
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const data: ToolUseData = JSON.parse(input);
|
|
39
|
+
|
|
40
|
+
// Only capture interesting tools
|
|
41
|
+
if (!INTERESTING_TOOLS.includes(data.tool_name)) {
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Get today's date for organization
|
|
46
|
+
const now = new Date();
|
|
47
|
+
const today = now.toISOString().split('T')[0]; // YYYY-MM-DD
|
|
48
|
+
const yearMonth = today.substring(0, 7); // YYYY-MM
|
|
49
|
+
|
|
50
|
+
// Ensure capture directory exists
|
|
51
|
+
const dateDir = join(CAPTURE_DIR, yearMonth);
|
|
52
|
+
if (!existsSync(dateDir)) {
|
|
53
|
+
mkdirSync(dateDir, { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Format output as JSONL (one JSON object per line)
|
|
57
|
+
const captureFile = join(dateDir, `${today}_tool-outputs.jsonl`);
|
|
58
|
+
const captureEntry = JSON.stringify({
|
|
59
|
+
timestamp: data.timestamp || now.toISOString(),
|
|
60
|
+
tool: data.tool_name,
|
|
61
|
+
input: data.tool_input,
|
|
62
|
+
output: data.tool_response,
|
|
63
|
+
session: data.conversation_id
|
|
64
|
+
}) + '\n';
|
|
65
|
+
|
|
66
|
+
// Append to daily log
|
|
67
|
+
appendFileSync(captureFile, captureEntry);
|
|
68
|
+
|
|
69
|
+
// Exit successfully (code 0 = continue normally)
|
|
70
|
+
process.exit(0);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// Silent failure - don't disrupt workflow
|
|
73
|
+
console.error(`[UOCS] PostToolUse hook error: ${error}`);
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
main();
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sync-todo-to-md.ts
|
|
4
|
+
*
|
|
5
|
+
* PostToolUse hook for TodoWrite that:
|
|
6
|
+
* 1. Syncs Claude's todos to TODO.md "Current Session" section
|
|
7
|
+
* 2. PRESERVES all user-managed sections (Plans, Completed, Backlog, etc.)
|
|
8
|
+
* 3. Adds completed items to the session note
|
|
9
|
+
*
|
|
10
|
+
* IMPORTANT: This hook PRESERVES user content. It only updates "Current Session".
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { writeFileSync, existsSync, readFileSync, mkdirSync } from 'fs';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
import {
|
|
16
|
+
findTodoPath,
|
|
17
|
+
findNotesDir,
|
|
18
|
+
getCurrentNotePath,
|
|
19
|
+
addWorkToSessionNote,
|
|
20
|
+
type WorkItem
|
|
21
|
+
} from '../lib/project-utils';
|
|
22
|
+
|
|
23
|
+
interface TodoItem {
|
|
24
|
+
content: string;
|
|
25
|
+
status: 'pending' | 'in_progress' | 'completed';
|
|
26
|
+
activeForm: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface HookInput {
|
|
30
|
+
session_id: string;
|
|
31
|
+
cwd: string;
|
|
32
|
+
tool_name: string;
|
|
33
|
+
tool_input: {
|
|
34
|
+
todos: TodoItem[];
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Format current session todos as markdown
|
|
40
|
+
*/
|
|
41
|
+
function formatSessionTodos(todos: TodoItem[]): string {
|
|
42
|
+
const inProgress = todos.filter(t => t.status === 'in_progress');
|
|
43
|
+
const pending = todos.filter(t => t.status === 'pending');
|
|
44
|
+
const completed = todos.filter(t => t.status === 'completed');
|
|
45
|
+
|
|
46
|
+
let content = '';
|
|
47
|
+
|
|
48
|
+
if (inProgress.length > 0) {
|
|
49
|
+
content += `### In Progress\n\n`;
|
|
50
|
+
for (const todo of inProgress) {
|
|
51
|
+
content += `- [ ] **${todo.content}** _(${todo.activeForm})_\n`;
|
|
52
|
+
}
|
|
53
|
+
content += '\n';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (pending.length > 0) {
|
|
57
|
+
content += `### Pending\n\n`;
|
|
58
|
+
for (const todo of pending) {
|
|
59
|
+
content += `- [ ] ${todo.content}\n`;
|
|
60
|
+
}
|
|
61
|
+
content += '\n';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (completed.length > 0) {
|
|
65
|
+
content += `### Completed\n\n`;
|
|
66
|
+
for (const todo of completed) {
|
|
67
|
+
content += `- [x] ${todo.content}\n`;
|
|
68
|
+
}
|
|
69
|
+
content += '\n';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (todos.length === 0) {
|
|
73
|
+
content += `_(No active session tasks)_\n\n`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return content;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extract all sections from TODO.md EXCEPT "Current Session"
|
|
81
|
+
* These are user-managed sections that should be preserved.
|
|
82
|
+
*/
|
|
83
|
+
function extractPreservedSections(content: string): string {
|
|
84
|
+
let preserved = '';
|
|
85
|
+
|
|
86
|
+
// Match all ## sections that are NOT "Current Session"
|
|
87
|
+
const sectionRegex = /\n(## (?!Current Session)[^\n]+[\s\S]*?)(?=\n## |\n---\n+\*Last updated|$)/g;
|
|
88
|
+
const matches = content.matchAll(sectionRegex);
|
|
89
|
+
|
|
90
|
+
for (const match of matches) {
|
|
91
|
+
preserved += match[1];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return preserved;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Fix malformed headings: Remove --- prefix from headings (---# → #)
|
|
99
|
+
* Claude sometimes incorrectly merges horizontal rules with headings.
|
|
100
|
+
*/
|
|
101
|
+
function fixMalformedHeadings(content: string): string {
|
|
102
|
+
return content.replace(/^---#/gm, '#');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build new TODO.md preserving user sections
|
|
107
|
+
*/
|
|
108
|
+
function buildTodoContent(todos: TodoItem[], existingContent: string): string {
|
|
109
|
+
const now = new Date().toISOString();
|
|
110
|
+
|
|
111
|
+
// Get all preserved sections (everything except Current Session)
|
|
112
|
+
const preserved = extractPreservedSections(existingContent);
|
|
113
|
+
|
|
114
|
+
// Build new content
|
|
115
|
+
let content = `# TODO
|
|
116
|
+
|
|
117
|
+
## Current Session
|
|
118
|
+
|
|
119
|
+
${formatSessionTodos(todos)}`;
|
|
120
|
+
|
|
121
|
+
// Add preserved sections
|
|
122
|
+
if (preserved.trim()) {
|
|
123
|
+
content += preserved;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Ensure we end with exactly one timestamp
|
|
127
|
+
content = content.replace(/(\n---\s*)*(\n\*Last updated:.*\*\s*)*$/, '');
|
|
128
|
+
content += `\n---\n\n*Last updated: ${now}*\n`;
|
|
129
|
+
|
|
130
|
+
return content;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function main() {
|
|
134
|
+
try {
|
|
135
|
+
const chunks: Buffer[] = [];
|
|
136
|
+
for await (const chunk of process.stdin) {
|
|
137
|
+
chunks.push(chunk);
|
|
138
|
+
}
|
|
139
|
+
const stdinData = Buffer.concat(chunks).toString('utf-8');
|
|
140
|
+
|
|
141
|
+
if (!stdinData.trim()) {
|
|
142
|
+
console.error('No input received');
|
|
143
|
+
process.exit(0);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const hookInput: HookInput = JSON.parse(stdinData);
|
|
147
|
+
|
|
148
|
+
if (hookInput.tool_name !== 'TodoWrite') {
|
|
149
|
+
process.exit(0);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const todos = hookInput.tool_input?.todos;
|
|
153
|
+
|
|
154
|
+
if (!todos || !Array.isArray(todos)) {
|
|
155
|
+
console.error('No todos in tool input');
|
|
156
|
+
process.exit(0);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const cwd = hookInput.cwd || process.cwd();
|
|
160
|
+
|
|
161
|
+
// Find TODO.md path
|
|
162
|
+
const todoPath = findTodoPath(cwd);
|
|
163
|
+
|
|
164
|
+
// Create TODO.md if it doesn't exist
|
|
165
|
+
if (!existsSync(todoPath)) {
|
|
166
|
+
const parentDir = todoPath.replace(/\/[^/]+$/, '');
|
|
167
|
+
mkdirSync(parentDir, { recursive: true });
|
|
168
|
+
console.error(`Creating TODO.md at ${todoPath}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Read existing content to preserve user sections
|
|
172
|
+
let existingContent = '';
|
|
173
|
+
try {
|
|
174
|
+
existingContent = readFileSync(todoPath, 'utf-8');
|
|
175
|
+
} catch (e) {
|
|
176
|
+
// New file, no content to preserve
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Build and write new content (with heading fix)
|
|
180
|
+
let newContent = buildTodoContent(todos, existingContent);
|
|
181
|
+
newContent = fixMalformedHeadings(newContent);
|
|
182
|
+
writeFileSync(todoPath, newContent);
|
|
183
|
+
|
|
184
|
+
const stats = {
|
|
185
|
+
inProgress: todos.filter(t => t.status === 'in_progress').length,
|
|
186
|
+
pending: todos.filter(t => t.status === 'pending').length,
|
|
187
|
+
completed: todos.filter(t => t.status === 'completed').length
|
|
188
|
+
};
|
|
189
|
+
console.error(`TODO.md synced: ${stats.inProgress} in progress, ${stats.pending} pending, ${stats.completed} completed`);
|
|
190
|
+
|
|
191
|
+
// Add completed items to session note (if local Notes/ exists)
|
|
192
|
+
const completedTodos = todos.filter(t => t.status === 'completed');
|
|
193
|
+
|
|
194
|
+
if (completedTodos.length > 0) {
|
|
195
|
+
const notesInfo = findNotesDir(cwd);
|
|
196
|
+
|
|
197
|
+
if (notesInfo.isLocal) {
|
|
198
|
+
const currentNotePath = getCurrentNotePath(notesInfo.path);
|
|
199
|
+
|
|
200
|
+
if (currentNotePath) {
|
|
201
|
+
let noteContent = '';
|
|
202
|
+
try {
|
|
203
|
+
noteContent = readFileSync(currentNotePath, 'utf-8');
|
|
204
|
+
} catch (e) {
|
|
205
|
+
console.error('Could not read session note:', e);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const newlyCompleted = completedTodos.filter(t => !noteContent.includes(t.content));
|
|
209
|
+
|
|
210
|
+
if (newlyCompleted.length > 0) {
|
|
211
|
+
const workItems: WorkItem[] = newlyCompleted.map(t => ({
|
|
212
|
+
title: t.content,
|
|
213
|
+
completed: true
|
|
214
|
+
}));
|
|
215
|
+
|
|
216
|
+
addWorkToSessionNote(currentNotePath, workItems);
|
|
217
|
+
console.error(`Added ${newlyCompleted.length} completed item(s) to session note`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
} catch (error) {
|
|
224
|
+
console.error('sync-todo-to-md error:', error);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
process.exit(0);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
main();
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Tab Title Update on Action Tools
|
|
4
|
+
* Updates Kitty tab title when action tools complete (Edit, Write, Bash, Task)
|
|
5
|
+
* Uses the tool's description directly - no AI call needed
|
|
6
|
+
*
|
|
7
|
+
* PAI-safe: No sensitive data, just tool descriptions
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execSync } from 'child_process';
|
|
11
|
+
|
|
12
|
+
// Action tools that warrant a tab title update
|
|
13
|
+
const ACTION_TOOLS = new Set([
|
|
14
|
+
'Edit',
|
|
15
|
+
'Write',
|
|
16
|
+
'Bash',
|
|
17
|
+
'Task',
|
|
18
|
+
'NotebookEdit',
|
|
19
|
+
'MultiEdit'
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
interface HookInput {
|
|
23
|
+
session_id: string;
|
|
24
|
+
tool_name: string;
|
|
25
|
+
tool_input: {
|
|
26
|
+
description?: string;
|
|
27
|
+
command?: string;
|
|
28
|
+
file_path?: string;
|
|
29
|
+
prompt?: string;
|
|
30
|
+
subagent_type?: string;
|
|
31
|
+
};
|
|
32
|
+
tool_response?: unknown;
|
|
33
|
+
hook_event_name: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read stdin with timeout
|
|
38
|
+
*/
|
|
39
|
+
async function readStdinWithTimeout(timeout: number = 3000): Promise<string> {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
let data = '';
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
reject(new Error('Timeout'));
|
|
44
|
+
}, timeout);
|
|
45
|
+
|
|
46
|
+
process.stdin.on('data', (chunk) => {
|
|
47
|
+
data += chunk.toString();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
process.stdin.on('end', () => {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
resolve(data);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
process.stdin.on('error', (err) => {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
reject(err);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Update Kitty tab title using escape codes
|
|
64
|
+
*/
|
|
65
|
+
function setTabTitle(title: string): void {
|
|
66
|
+
try {
|
|
67
|
+
// Truncate to reasonable length
|
|
68
|
+
const truncated = title.length > 50 ? title.slice(0, 47) + '...' : title;
|
|
69
|
+
const escaped = truncated.replace(/'/g, "'\\''");
|
|
70
|
+
|
|
71
|
+
// Multiple escape sequences for compatibility
|
|
72
|
+
execSync(`printf '\\033]0;${escaped}\\007' >&2`, { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
73
|
+
execSync(`printf '\\033]2;${escaped}\\007' >&2`, { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
74
|
+
execSync(`printf '\\033]30;${escaped}\\007' >&2`, { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
75
|
+
} catch {
|
|
76
|
+
// Silently fail - don't interrupt Claude's work
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Generate a human-readable title from tool input
|
|
82
|
+
*/
|
|
83
|
+
function generateTitle(toolName: string, input: HookInput['tool_input']): string {
|
|
84
|
+
// Use explicit description if provided
|
|
85
|
+
if (input.description) {
|
|
86
|
+
return input.description;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Generate based on tool type
|
|
90
|
+
switch (toolName) {
|
|
91
|
+
case 'Edit':
|
|
92
|
+
case 'Write':
|
|
93
|
+
case 'MultiEdit':
|
|
94
|
+
case 'NotebookEdit':
|
|
95
|
+
if (input.file_path) {
|
|
96
|
+
const filename = input.file_path.split('/').pop() || input.file_path;
|
|
97
|
+
return `Editing ${filename}`;
|
|
98
|
+
}
|
|
99
|
+
return `Editing file`;
|
|
100
|
+
|
|
101
|
+
case 'Bash':
|
|
102
|
+
if (input.command) {
|
|
103
|
+
// Extract first command word
|
|
104
|
+
const cmd = input.command.split(/\s+/)[0].split('/').pop() || 'command';
|
|
105
|
+
return `Running ${cmd}`;
|
|
106
|
+
}
|
|
107
|
+
return 'Running command';
|
|
108
|
+
|
|
109
|
+
case 'Task':
|
|
110
|
+
if (input.subagent_type) {
|
|
111
|
+
return `Agent: ${input.subagent_type}`;
|
|
112
|
+
}
|
|
113
|
+
if (input.prompt) {
|
|
114
|
+
const words = input.prompt.slice(0, 30).split(/\s+/).slice(0, 3).join(' ');
|
|
115
|
+
return `Task: ${words}...`;
|
|
116
|
+
}
|
|
117
|
+
return 'Spawning agent';
|
|
118
|
+
|
|
119
|
+
default:
|
|
120
|
+
return `${toolName}...`;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function main() {
|
|
125
|
+
try {
|
|
126
|
+
const input = await readStdinWithTimeout();
|
|
127
|
+
const data: HookInput = JSON.parse(input);
|
|
128
|
+
|
|
129
|
+
// Only process action tools
|
|
130
|
+
if (!ACTION_TOOLS.has(data.tool_name)) {
|
|
131
|
+
process.exit(0);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Generate and set title
|
|
135
|
+
const title = generateTitle(data.tool_name, data.tool_input);
|
|
136
|
+
setTabTitle(title);
|
|
137
|
+
|
|
138
|
+
process.exit(0);
|
|
139
|
+
} catch {
|
|
140
|
+
// Silently exit - don't interrupt Claude's flow
|
|
141
|
+
process.exit(0);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
main();
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreCompact Hook - Triggered before context compression
|
|
4
|
+
* Extracts context information from transcript and notifies about compression
|
|
5
|
+
*
|
|
6
|
+
* Enhanced to:
|
|
7
|
+
* - Save checkpoint to current session note
|
|
8
|
+
* - Send ntfy.sh notification
|
|
9
|
+
* - Calculate approximate token count
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from 'fs';
|
|
13
|
+
import { join, basename, dirname } from 'path';
|
|
14
|
+
import {
|
|
15
|
+
sendNtfyNotification,
|
|
16
|
+
getCurrentNotePath,
|
|
17
|
+
appendCheckpoint,
|
|
18
|
+
calculateSessionTokens
|
|
19
|
+
} from '../lib/project-utils';
|
|
20
|
+
|
|
21
|
+
interface HookInput {
|
|
22
|
+
session_id: string;
|
|
23
|
+
transcript_path: string;
|
|
24
|
+
hook_event_name: string;
|
|
25
|
+
compact_type?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface TranscriptEntry {
|
|
29
|
+
type: string;
|
|
30
|
+
message?: {
|
|
31
|
+
role?: string;
|
|
32
|
+
content?: Array<{
|
|
33
|
+
type: string;
|
|
34
|
+
text: string;
|
|
35
|
+
}>
|
|
36
|
+
};
|
|
37
|
+
timestamp?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Count messages in transcript to provide context
|
|
42
|
+
*/
|
|
43
|
+
function getTranscriptStats(transcriptPath: string): { messageCount: number; isLarge: boolean } {
|
|
44
|
+
try {
|
|
45
|
+
const content = readFileSync(transcriptPath, 'utf-8');
|
|
46
|
+
const lines = content.trim().split('\n');
|
|
47
|
+
|
|
48
|
+
let userMessages = 0;
|
|
49
|
+
let assistantMessages = 0;
|
|
50
|
+
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
if (line.trim()) {
|
|
53
|
+
try {
|
|
54
|
+
const entry = JSON.parse(line) as TranscriptEntry;
|
|
55
|
+
if (entry.type === 'user') {
|
|
56
|
+
userMessages++;
|
|
57
|
+
} else if (entry.type === 'assistant') {
|
|
58
|
+
assistantMessages++;
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// Skip invalid JSON lines
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const totalMessages = userMessages + assistantMessages;
|
|
67
|
+
const isLarge = totalMessages > 50; // Consider large if more than 50 messages
|
|
68
|
+
|
|
69
|
+
return { messageCount: totalMessages, isLarge };
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { messageCount: 0, isLarge: false };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function main() {
|
|
76
|
+
let hookInput: HookInput | null = null;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
// Read the JSON input from stdin
|
|
80
|
+
const decoder = new TextDecoder();
|
|
81
|
+
let input = '';
|
|
82
|
+
|
|
83
|
+
const timeoutPromise = new Promise<void>((resolve) => {
|
|
84
|
+
setTimeout(() => resolve(), 500);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const readPromise = (async () => {
|
|
88
|
+
for await (const chunk of process.stdin) {
|
|
89
|
+
input += decoder.decode(chunk, { stream: true });
|
|
90
|
+
}
|
|
91
|
+
})();
|
|
92
|
+
|
|
93
|
+
await Promise.race([readPromise, timeoutPromise]);
|
|
94
|
+
|
|
95
|
+
if (input.trim()) {
|
|
96
|
+
hookInput = JSON.parse(input) as HookInput;
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// Silently handle input errors
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Determine the type of compression
|
|
103
|
+
const compactType = hookInput?.compact_type || 'auto';
|
|
104
|
+
let message = 'Compressing context to continue';
|
|
105
|
+
|
|
106
|
+
// Get transcript statistics if available
|
|
107
|
+
let tokenCount = 0;
|
|
108
|
+
if (hookInput && hookInput.transcript_path) {
|
|
109
|
+
const stats = getTranscriptStats(hookInput.transcript_path);
|
|
110
|
+
|
|
111
|
+
// Calculate approximate token count
|
|
112
|
+
tokenCount = calculateSessionTokens(hookInput.transcript_path);
|
|
113
|
+
const tokenDisplay = tokenCount > 1000
|
|
114
|
+
? `${Math.round(tokenCount / 1000)}k`
|
|
115
|
+
: String(tokenCount);
|
|
116
|
+
|
|
117
|
+
if (stats.messageCount > 0) {
|
|
118
|
+
if (compactType === 'manual') {
|
|
119
|
+
message = `Manually compressing ${stats.messageCount} messages (~${tokenDisplay} tokens)`;
|
|
120
|
+
} else {
|
|
121
|
+
message = stats.isLarge
|
|
122
|
+
? `Auto-compressing large context (~${tokenDisplay} tokens)`
|
|
123
|
+
: `Compressing context (~${tokenDisplay} tokens)`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Save checkpoint to session note before compression
|
|
128
|
+
try {
|
|
129
|
+
const transcriptDir = dirname(hookInput.transcript_path);
|
|
130
|
+
const notesDir = join(transcriptDir, 'Notes');
|
|
131
|
+
const currentNotePath = getCurrentNotePath(notesDir);
|
|
132
|
+
|
|
133
|
+
if (currentNotePath) {
|
|
134
|
+
const checkpoint = `Context compression triggered at ~${tokenDisplay} tokens with ${stats.messageCount} messages.`;
|
|
135
|
+
appendCheckpoint(currentNotePath, checkpoint);
|
|
136
|
+
console.error(`Checkpoint saved before compression: ${basename(currentNotePath)}`);
|
|
137
|
+
}
|
|
138
|
+
} catch (noteError) {
|
|
139
|
+
console.error(`Could not save checkpoint: ${noteError}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Send ntfy.sh notification
|
|
144
|
+
const ntfyMessage = tokenCount > 0
|
|
145
|
+
? `Auto-pause: ~${Math.round(tokenCount / 1000)}k tokens`
|
|
146
|
+
: 'Context compressing';
|
|
147
|
+
await sendNtfyNotification(ntfyMessage);
|
|
148
|
+
|
|
149
|
+
process.exit(0);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Run the hook
|
|
153
|
+
main().catch(() => {
|
|
154
|
+
process.exit(0);
|
|
155
|
+
});
|