claude-cli-advanced-starter-pack 1.0.15 → 1.1.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/package.json +1 -1
- package/src/cli/menu.js +80 -0
- package/src/commands/init.js +3 -3
- package/src/data/releases.json +70 -0
- package/templates/hooks/context-injector.template.js +261 -0
- package/templates/hooks/happy-mode-detector.template.js +214 -0
- package/templates/hooks/happy-title-generator.template.js +260 -0
- package/templates/hooks/token-budget-loader.template.js +234 -0
- package/templates/hooks/tool-output-cacher.template.js +219 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Happy Title Generator Hook
|
|
3
|
+
*
|
|
4
|
+
* Auto-generates session titles with format: "Issue #XX - Summary"
|
|
5
|
+
* Detects GitHub issue numbers from branch names.
|
|
6
|
+
* Notifies Happy daemon of title updates for mobile display.
|
|
7
|
+
*
|
|
8
|
+
* Event: UserPromptSubmit
|
|
9
|
+
*
|
|
10
|
+
* Configuration: Reads from .claude/config/hooks-config.json
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { execSync } = require('child_process');
|
|
16
|
+
|
|
17
|
+
// Default configuration (can be overridden by hooks-config.json)
|
|
18
|
+
const DEFAULT_CONFIG = {
|
|
19
|
+
rate_limit_ms: 30000, // Throttle title updates to once per 30 sec
|
|
20
|
+
max_summary_words: 4, // Maximum words in title summary
|
|
21
|
+
issue_pattern: 'issue-(\\d+)', // Regex to extract issue number from branch
|
|
22
|
+
common_prefixes: [ // Prefixes to remove from summaries
|
|
23
|
+
'implement', 'add', 'fix', 'update', 'create',
|
|
24
|
+
'refactor', 'build', 'remove', 'optimize', 'improve'
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Paths
|
|
29
|
+
const CONFIG_PATH = path.join(process.cwd(), '.claude', 'config', 'hooks-config.json');
|
|
30
|
+
const STATE_PATH = path.join(process.cwd(), '.claude', 'config', 'title-generator-state.json');
|
|
31
|
+
const HAPPY_STATE_PATH = path.join(process.env.HOME || process.env.USERPROFILE, '.happy', 'daemon.state.json');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Load configuration with defaults
|
|
35
|
+
*/
|
|
36
|
+
function loadConfig() {
|
|
37
|
+
try {
|
|
38
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
39
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
40
|
+
return { ...DEFAULT_CONFIG, ...(config.title_generator || {}) };
|
|
41
|
+
}
|
|
42
|
+
} catch (e) {
|
|
43
|
+
// Use defaults on error
|
|
44
|
+
}
|
|
45
|
+
return DEFAULT_CONFIG;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Load state
|
|
50
|
+
*/
|
|
51
|
+
function loadState() {
|
|
52
|
+
try {
|
|
53
|
+
if (fs.existsSync(STATE_PATH)) {
|
|
54
|
+
return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
|
|
55
|
+
}
|
|
56
|
+
} catch (e) {
|
|
57
|
+
// Return fresh state
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
last_update: 0,
|
|
61
|
+
current_title: null,
|
|
62
|
+
issue_number: null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Save state
|
|
68
|
+
*/
|
|
69
|
+
function saveState(state) {
|
|
70
|
+
try {
|
|
71
|
+
const dir = path.dirname(STATE_PATH);
|
|
72
|
+
if (!fs.existsSync(dir)) {
|
|
73
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), 'utf8');
|
|
76
|
+
} catch (e) {
|
|
77
|
+
// Silent failure
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get current git branch
|
|
83
|
+
*/
|
|
84
|
+
function getCurrentBranch() {
|
|
85
|
+
try {
|
|
86
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
87
|
+
encoding: 'utf8',
|
|
88
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
89
|
+
timeout: 5000,
|
|
90
|
+
}).trim();
|
|
91
|
+
return branch;
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Extract issue number from branch name
|
|
99
|
+
*/
|
|
100
|
+
function extractIssueNumber(branch, config) {
|
|
101
|
+
if (!branch) return null;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const pattern = new RegExp(config.issue_pattern, 'i');
|
|
105
|
+
const match = branch.match(pattern);
|
|
106
|
+
if (match && match[1]) {
|
|
107
|
+
return parseInt(match[1], 10);
|
|
108
|
+
}
|
|
109
|
+
} catch (e) {
|
|
110
|
+
// Invalid regex pattern
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Try common patterns as fallback
|
|
114
|
+
const fallbackPatterns = [
|
|
115
|
+
/issue-(\d+)/i,
|
|
116
|
+
/(\d+)-/,
|
|
117
|
+
/#(\d+)/,
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
for (const pattern of fallbackPatterns) {
|
|
121
|
+
const match = branch.match(pattern);
|
|
122
|
+
if (match && match[1]) {
|
|
123
|
+
return parseInt(match[1], 10);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Generate summary from user prompt
|
|
132
|
+
*/
|
|
133
|
+
function generateSummary(prompt, config) {
|
|
134
|
+
if (!prompt) return 'New Session';
|
|
135
|
+
|
|
136
|
+
// Clean the prompt
|
|
137
|
+
let summary = prompt.trim();
|
|
138
|
+
|
|
139
|
+
// Remove common prefixes
|
|
140
|
+
const prefixPattern = new RegExp(`^(${config.common_prefixes.join('|')})\\s+`, 'i');
|
|
141
|
+
summary = summary.replace(prefixPattern, '');
|
|
142
|
+
|
|
143
|
+
// Take first N words
|
|
144
|
+
const words = summary.split(/\s+/).slice(0, config.max_summary_words);
|
|
145
|
+
summary = words.join(' ');
|
|
146
|
+
|
|
147
|
+
// Capitalize first letter
|
|
148
|
+
if (summary.length > 0) {
|
|
149
|
+
summary = summary.charAt(0).toUpperCase() + summary.slice(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Truncate if too long
|
|
153
|
+
if (summary.length > 50) {
|
|
154
|
+
summary = summary.substring(0, 47) + '...';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return summary || 'New Session';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Format title with issue number
|
|
162
|
+
*/
|
|
163
|
+
function formatTitle(issueNumber, summary, branch) {
|
|
164
|
+
let title = '';
|
|
165
|
+
|
|
166
|
+
if (issueNumber) {
|
|
167
|
+
title = `Issue #${issueNumber} - ${summary}`;
|
|
168
|
+
} else {
|
|
169
|
+
title = summary;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Add branch info if available
|
|
173
|
+
if (branch && branch !== 'main' && branch !== 'master') {
|
|
174
|
+
const shortBranch = branch.length > 20 ? branch.substring(0, 17) + '...' : branch;
|
|
175
|
+
title = `${title} | ${shortBranch}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return title;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Notify Happy daemon of title update
|
|
183
|
+
*/
|
|
184
|
+
function notifyHappyDaemon(title) {
|
|
185
|
+
try {
|
|
186
|
+
if (!fs.existsSync(HAPPY_STATE_PATH)) {
|
|
187
|
+
return false; // Happy daemon not running
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const happyState = JSON.parse(fs.readFileSync(HAPPY_STATE_PATH, 'utf8'));
|
|
191
|
+
|
|
192
|
+
// Update session title in daemon state
|
|
193
|
+
happyState.current_session = happyState.current_session || {};
|
|
194
|
+
happyState.current_session.title = title;
|
|
195
|
+
happyState.current_session.updated_at = new Date().toISOString();
|
|
196
|
+
|
|
197
|
+
// Write atomically using temp file
|
|
198
|
+
const tempPath = HAPPY_STATE_PATH + '.tmp';
|
|
199
|
+
fs.writeFileSync(tempPath, JSON.stringify(happyState, null, 2), 'utf8');
|
|
200
|
+
fs.renameSync(tempPath, HAPPY_STATE_PATH);
|
|
201
|
+
|
|
202
|
+
return true;
|
|
203
|
+
} catch (e) {
|
|
204
|
+
return false; // Silent failure - daemon may not be running
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Main hook handler
|
|
210
|
+
*/
|
|
211
|
+
module.exports = async function happyTitleGenerator(context) {
|
|
212
|
+
// Always continue - never block
|
|
213
|
+
const approve = () => ({ continue: true });
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const config = loadConfig();
|
|
217
|
+
const state = loadState();
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
|
|
220
|
+
// Rate limiting - don't update too frequently
|
|
221
|
+
if (state.last_update && (now - state.last_update) < config.rate_limit_ms) {
|
|
222
|
+
return approve();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Parse hook input for user prompt
|
|
226
|
+
let userPrompt = '';
|
|
227
|
+
try {
|
|
228
|
+
const input = JSON.parse(process.env.CLAUDE_HOOK_INPUT || '{}');
|
|
229
|
+
userPrompt = input.prompt || input.message || '';
|
|
230
|
+
} catch (e) {
|
|
231
|
+
// No prompt available
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Get git context
|
|
235
|
+
const branch = getCurrentBranch();
|
|
236
|
+
const issueNumber = extractIssueNumber(branch, config);
|
|
237
|
+
|
|
238
|
+
// Generate title components
|
|
239
|
+
const summary = generateSummary(userPrompt, config);
|
|
240
|
+
const title = formatTitle(issueNumber, summary, branch);
|
|
241
|
+
|
|
242
|
+
// Update state
|
|
243
|
+
state.last_update = now;
|
|
244
|
+
state.current_title = title;
|
|
245
|
+
state.issue_number = issueNumber;
|
|
246
|
+
saveState(state);
|
|
247
|
+
|
|
248
|
+
// Notify Happy daemon (if running)
|
|
249
|
+
const notified = notifyHappyDaemon(title);
|
|
250
|
+
|
|
251
|
+
if (notified) {
|
|
252
|
+
console.log(`[happy-title-generator] Title updated: ${title}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return approve();
|
|
256
|
+
} catch (error) {
|
|
257
|
+
console.error(`[happy-title-generator] Error: ${error.message}`);
|
|
258
|
+
return approve();
|
|
259
|
+
}
|
|
260
|
+
};
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Budget Loader Hook
|
|
3
|
+
*
|
|
4
|
+
* Pre-calculates daily token budget at session start.
|
|
5
|
+
* Tracks usage across sessions and provides health status alerts.
|
|
6
|
+
* Saves ~5K tokens/session by pre-loading budget state.
|
|
7
|
+
*
|
|
8
|
+
* Event: UserPromptSubmit (runs once per session)
|
|
9
|
+
*
|
|
10
|
+
* Configuration: Reads from .claude/config/hooks-config.json
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
// Default configuration (can be overridden by hooks-config.json)
|
|
17
|
+
const DEFAULT_CONFIG = {
|
|
18
|
+
daily_limit: 200000, // Daily token budget
|
|
19
|
+
warning_percent: 60, // Warn at this percentage
|
|
20
|
+
critical_percent: 80, // Critical warning at this percentage
|
|
21
|
+
reset_hours: 24, // Budget reset cycle in hours
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Paths
|
|
25
|
+
const CONFIG_PATH = path.join(process.cwd(), '.claude', 'config', 'hooks-config.json');
|
|
26
|
+
const BUDGET_PATH = path.join(process.cwd(), '.claude', 'config', 'token-budget.json');
|
|
27
|
+
const SESSION_MARKER = path.join(process.cwd(), '.claude', 'config', '.budget-loaded');
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Load configuration with defaults
|
|
31
|
+
*/
|
|
32
|
+
function loadConfig() {
|
|
33
|
+
try {
|
|
34
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
35
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
36
|
+
return { ...DEFAULT_CONFIG, ...(config.token_budget || {}) };
|
|
37
|
+
}
|
|
38
|
+
} catch (e) {
|
|
39
|
+
// Use defaults on error
|
|
40
|
+
}
|
|
41
|
+
return DEFAULT_CONFIG;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Load budget state
|
|
46
|
+
*/
|
|
47
|
+
function loadBudget() {
|
|
48
|
+
try {
|
|
49
|
+
if (fs.existsSync(BUDGET_PATH)) {
|
|
50
|
+
return JSON.parse(fs.readFileSync(BUDGET_PATH, 'utf8'));
|
|
51
|
+
}
|
|
52
|
+
} catch (e) {
|
|
53
|
+
// Return fresh budget
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Save budget state
|
|
60
|
+
*/
|
|
61
|
+
function saveBudget(budget) {
|
|
62
|
+
try {
|
|
63
|
+
const dir = path.dirname(BUDGET_PATH);
|
|
64
|
+
if (!fs.existsSync(dir)) {
|
|
65
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
fs.writeFileSync(BUDGET_PATH, JSON.stringify(budget, null, 2), 'utf8');
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.error(`[token-budget-loader] Failed to save budget: ${e.message}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check if we've already loaded budget this session
|
|
75
|
+
*/
|
|
76
|
+
function hasLoadedThisSession() {
|
|
77
|
+
try {
|
|
78
|
+
if (fs.existsSync(SESSION_MARKER)) {
|
|
79
|
+
const content = fs.readFileSync(SESSION_MARKER, 'utf8');
|
|
80
|
+
const timestamp = parseInt(content, 10);
|
|
81
|
+
// Session valid for 4 hours
|
|
82
|
+
if (Date.now() - timestamp < 4 * 60 * 60 * 1000) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch (e) {
|
|
87
|
+
// Continue with loading
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Mark session as loaded
|
|
94
|
+
*/
|
|
95
|
+
function markSessionLoaded() {
|
|
96
|
+
try {
|
|
97
|
+
const dir = path.dirname(SESSION_MARKER);
|
|
98
|
+
if (!fs.existsSync(dir)) {
|
|
99
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
100
|
+
}
|
|
101
|
+
fs.writeFileSync(SESSION_MARKER, Date.now().toString(), 'utf8');
|
|
102
|
+
} catch (e) {
|
|
103
|
+
// Silent failure
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Check if budget should reset (new day)
|
|
109
|
+
*/
|
|
110
|
+
function shouldResetBudget(budget, config) {
|
|
111
|
+
if (!budget || !budget.reset_at) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const resetAt = new Date(budget.reset_at);
|
|
116
|
+
const now = new Date();
|
|
117
|
+
|
|
118
|
+
return now >= resetAt;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Calculate next reset time
|
|
123
|
+
*/
|
|
124
|
+
function getNextResetTime(config) {
|
|
125
|
+
const now = new Date();
|
|
126
|
+
const resetTime = new Date(now);
|
|
127
|
+
|
|
128
|
+
// Reset at midnight UTC by default
|
|
129
|
+
resetTime.setUTCHours(0, 0, 0, 0);
|
|
130
|
+
resetTime.setTime(resetTime.getTime() + config.reset_hours * 60 * 60 * 1000);
|
|
131
|
+
|
|
132
|
+
// If we've passed today's reset, move to next cycle
|
|
133
|
+
while (resetTime <= now) {
|
|
134
|
+
resetTime.setTime(resetTime.getTime() + config.reset_hours * 60 * 60 * 1000);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return resetTime.toISOString();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Estimate tokens from characters (rough approximation)
|
|
142
|
+
* Claude uses ~4 chars per token on average
|
|
143
|
+
*/
|
|
144
|
+
function estimateTokens(chars) {
|
|
145
|
+
return Math.ceil(chars / 4);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get budget health status
|
|
150
|
+
*/
|
|
151
|
+
function getBudgetHealth(budget, config) {
|
|
152
|
+
const percentUsed = (budget.used / budget.limit) * 100;
|
|
153
|
+
|
|
154
|
+
if (percentUsed >= config.critical_percent) {
|
|
155
|
+
return {
|
|
156
|
+
status: 'critical',
|
|
157
|
+
message: `CRITICAL: ${percentUsed.toFixed(1)}% of daily budget used`,
|
|
158
|
+
color: 'red',
|
|
159
|
+
};
|
|
160
|
+
} else if (percentUsed >= config.warning_percent) {
|
|
161
|
+
return {
|
|
162
|
+
status: 'warning',
|
|
163
|
+
message: `WARNING: ${percentUsed.toFixed(1)}% of daily budget used`,
|
|
164
|
+
color: 'yellow',
|
|
165
|
+
};
|
|
166
|
+
} else {
|
|
167
|
+
return {
|
|
168
|
+
status: 'healthy',
|
|
169
|
+
message: `Budget healthy: ${percentUsed.toFixed(1)}% used`,
|
|
170
|
+
color: 'green',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Main hook handler
|
|
177
|
+
*/
|
|
178
|
+
module.exports = async function tokenBudgetLoader(context) {
|
|
179
|
+
// Always continue - never block
|
|
180
|
+
const approve = () => ({ continue: true });
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
// Only run once per session
|
|
184
|
+
if (hasLoadedThisSession()) {
|
|
185
|
+
return approve();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Mark this session as loaded
|
|
189
|
+
markSessionLoaded();
|
|
190
|
+
|
|
191
|
+
const config = loadConfig();
|
|
192
|
+
let budget = loadBudget();
|
|
193
|
+
|
|
194
|
+
// Check if we need to reset the budget
|
|
195
|
+
if (shouldResetBudget(budget, config)) {
|
|
196
|
+
budget = {
|
|
197
|
+
limit: config.daily_limit,
|
|
198
|
+
used: 0,
|
|
199
|
+
reset_at: getNextResetTime(config),
|
|
200
|
+
sessions: [],
|
|
201
|
+
created_at: new Date().toISOString(),
|
|
202
|
+
};
|
|
203
|
+
saveBudget(budget);
|
|
204
|
+
console.log(`[token-budget-loader] Budget reset. Daily limit: ${config.daily_limit.toLocaleString()} tokens`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Calculate remaining budget
|
|
208
|
+
const remaining = budget.limit - budget.used;
|
|
209
|
+
const health = getBudgetHealth(budget, config);
|
|
210
|
+
|
|
211
|
+
// Log budget status
|
|
212
|
+
console.log(`[token-budget-loader] ${health.message}`);
|
|
213
|
+
console.log(`[token-budget-loader] Remaining: ${remaining.toLocaleString()} tokens`);
|
|
214
|
+
console.log(`[token-budget-loader] Resets at: ${budget.reset_at}`);
|
|
215
|
+
|
|
216
|
+
// Record this session start
|
|
217
|
+
budget.sessions.push({
|
|
218
|
+
started_at: new Date().toISOString(),
|
|
219
|
+
initial_remaining: remaining,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Keep only last 10 sessions
|
|
223
|
+
if (budget.sessions.length > 10) {
|
|
224
|
+
budget.sessions = budget.sessions.slice(-10);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
saveBudget(budget);
|
|
228
|
+
|
|
229
|
+
return approve();
|
|
230
|
+
} catch (error) {
|
|
231
|
+
console.error(`[token-budget-loader] Error: ${error.message}`);
|
|
232
|
+
return approve();
|
|
233
|
+
}
|
|
234
|
+
};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Output Cacher Hook
|
|
3
|
+
*
|
|
4
|
+
* Caches large tool outputs (>2KB) to reduce context consumption.
|
|
5
|
+
* Monitors cumulative session output and warns at thresholds.
|
|
6
|
+
* Saves ~500 tokens per large output by storing in cache files.
|
|
7
|
+
*
|
|
8
|
+
* Event: PostToolUse
|
|
9
|
+
*
|
|
10
|
+
* Configuration: Reads from .claude/config/hooks-config.json
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
|
|
17
|
+
// Default configuration (can be overridden by hooks-config.json)
|
|
18
|
+
const DEFAULT_CONFIG = {
|
|
19
|
+
threshold_chars: 2048, // Cache outputs larger than this
|
|
20
|
+
warning_threshold: 100000, // Warn when session total exceeds this
|
|
21
|
+
critical_threshold: 150000, // Critical warning at this level
|
|
22
|
+
session_timeout_ms: 300000, // 5 minutes - consider new session
|
|
23
|
+
cacheable_tools: ['Bash', 'Read', 'Glob', 'Grep'],
|
|
24
|
+
bypass_tools: ['Task', 'TaskOutput'],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Paths
|
|
28
|
+
const CONFIG_PATH = path.join(process.cwd(), '.claude', 'config', 'hooks-config.json');
|
|
29
|
+
const CACHE_DIR = path.join(process.cwd(), '.claude', 'cache', 'tool-outputs');
|
|
30
|
+
const STATE_FILE = path.join(process.cwd(), '.claude', 'config', 'tool-output-cacher-state.json');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Load configuration with defaults
|
|
34
|
+
*/
|
|
35
|
+
function loadConfig() {
|
|
36
|
+
try {
|
|
37
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
38
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
39
|
+
return { ...DEFAULT_CONFIG, ...(config.output_caching || {}) };
|
|
40
|
+
}
|
|
41
|
+
} catch (e) {
|
|
42
|
+
// Use defaults on error
|
|
43
|
+
}
|
|
44
|
+
return DEFAULT_CONFIG;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Load session state
|
|
49
|
+
*/
|
|
50
|
+
function loadState() {
|
|
51
|
+
try {
|
|
52
|
+
if (fs.existsSync(STATE_FILE)) {
|
|
53
|
+
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
54
|
+
}
|
|
55
|
+
} catch (e) {
|
|
56
|
+
// Return fresh state
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
session_id: null,
|
|
60
|
+
session_start: null,
|
|
61
|
+
cumulative_chars: 0,
|
|
62
|
+
cached_count: 0,
|
|
63
|
+
last_activity: null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Save session state
|
|
69
|
+
*/
|
|
70
|
+
function saveState(state) {
|
|
71
|
+
try {
|
|
72
|
+
const dir = path.dirname(STATE_FILE);
|
|
73
|
+
if (!fs.existsSync(dir)) {
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf8');
|
|
77
|
+
} catch (e) {
|
|
78
|
+
// Silent failure - don't block tool execution
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Generate cache key for output
|
|
84
|
+
*/
|
|
85
|
+
function generateCacheKey(toolName, output) {
|
|
86
|
+
const hash = crypto.createHash('md5').update(output.slice(0, 500)).digest('hex').slice(0, 8);
|
|
87
|
+
return `${toolName.toLowerCase()}-${Date.now()}-${hash}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Cache output to file
|
|
92
|
+
*/
|
|
93
|
+
function cacheOutput(key, output, summary) {
|
|
94
|
+
try {
|
|
95
|
+
if (!fs.existsSync(CACHE_DIR)) {
|
|
96
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const cachePath = path.join(CACHE_DIR, `${key}.txt`);
|
|
100
|
+
fs.writeFileSync(cachePath, output, 'utf8');
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
cached: true,
|
|
104
|
+
path: cachePath,
|
|
105
|
+
summary: summary,
|
|
106
|
+
};
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return { cached: false, error: e.message };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Generate summary of large output
|
|
114
|
+
*/
|
|
115
|
+
function generateSummary(output, maxLines = 15) {
|
|
116
|
+
const lines = output.split('\n');
|
|
117
|
+
const totalLines = lines.length;
|
|
118
|
+
const totalChars = output.length;
|
|
119
|
+
|
|
120
|
+
// Take first N lines as preview
|
|
121
|
+
const preview = lines.slice(0, maxLines).join('\n');
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
preview: preview,
|
|
125
|
+
total_lines: totalLines,
|
|
126
|
+
total_chars: totalChars,
|
|
127
|
+
truncated: totalLines > maxLines,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Check if tool output should be cached
|
|
133
|
+
*/
|
|
134
|
+
function shouldCache(toolName, output, config) {
|
|
135
|
+
// Skip bypass tools
|
|
136
|
+
if (config.bypass_tools.includes(toolName)) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check if tool is cacheable
|
|
141
|
+
const isCacheable = config.cacheable_tools.some(t =>
|
|
142
|
+
toolName.startsWith(t) || toolName === t
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if (!isCacheable) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check size threshold
|
|
150
|
+
return output.length > config.threshold_chars;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Main hook handler
|
|
155
|
+
*/
|
|
156
|
+
module.exports = async function toolOutputCacher(context) {
|
|
157
|
+
// Always approve - this is a PostToolUse hook for monitoring only
|
|
158
|
+
const approve = () => ({ continue: true });
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
// Parse hook input
|
|
162
|
+
const input = JSON.parse(process.env.CLAUDE_HOOK_INPUT || '{}');
|
|
163
|
+
const { tool_name, tool_input, tool_output } = input;
|
|
164
|
+
|
|
165
|
+
if (!tool_name || !tool_output) {
|
|
166
|
+
return approve();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const output = typeof tool_output === 'string' ? tool_output : JSON.stringify(tool_output);
|
|
170
|
+
const config = loadConfig();
|
|
171
|
+
|
|
172
|
+
// Load and update session state
|
|
173
|
+
let state = loadState();
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
|
|
176
|
+
// Check if this is a new session
|
|
177
|
+
if (!state.session_start || (now - state.last_activity) > config.session_timeout_ms) {
|
|
178
|
+
state = {
|
|
179
|
+
session_id: crypto.randomBytes(4).toString('hex'),
|
|
180
|
+
session_start: now,
|
|
181
|
+
cumulative_chars: 0,
|
|
182
|
+
cached_count: 0,
|
|
183
|
+
last_activity: now,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Update cumulative tracking
|
|
188
|
+
state.cumulative_chars += output.length;
|
|
189
|
+
state.last_activity = now;
|
|
190
|
+
|
|
191
|
+
// Check if we should cache this output
|
|
192
|
+
if (shouldCache(tool_name, output, config)) {
|
|
193
|
+
const summary = generateSummary(output);
|
|
194
|
+
const cacheKey = generateCacheKey(tool_name, output);
|
|
195
|
+
const cacheResult = cacheOutput(cacheKey, output, summary);
|
|
196
|
+
|
|
197
|
+
if (cacheResult.cached) {
|
|
198
|
+
state.cached_count++;
|
|
199
|
+
console.log(`[tool-output-cacher] Cached ${tool_name} output (${output.length} chars) -> ${cacheResult.path}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Check thresholds and warn
|
|
204
|
+
if (state.cumulative_chars >= config.critical_threshold) {
|
|
205
|
+
console.log(`[tool-output-cacher] CRITICAL: Session at ${state.cumulative_chars} chars. Consider compaction.`);
|
|
206
|
+
} else if (state.cumulative_chars >= config.warning_threshold) {
|
|
207
|
+
console.log(`[tool-output-cacher] WARNING: Session at ${state.cumulative_chars} chars.`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Save state
|
|
211
|
+
saveState(state);
|
|
212
|
+
|
|
213
|
+
return approve();
|
|
214
|
+
} catch (error) {
|
|
215
|
+
// Always approve even on error
|
|
216
|
+
console.error(`[tool-output-cacher] Error: ${error.message}`);
|
|
217
|
+
return approve();
|
|
218
|
+
}
|
|
219
|
+
};
|