claude-cli-advanced-starter-pack 1.0.16 → 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/commands/init.js +3 -3
- package/src/data/releases.json +45 -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
package/package.json
CHANGED
package/src/commands/init.js
CHANGED
|
@@ -42,7 +42,7 @@ const OPTIONAL_FEATURES = [
|
|
|
42
42
|
label: 'Token Budget Management',
|
|
43
43
|
description: 'Monitor and manage Claude API token usage with automatic compaction warnings, archive suggestions, and respawn thresholds. Includes hooks that track usage per session.',
|
|
44
44
|
commands: ['context-audit'],
|
|
45
|
-
hooks: ['context-guardian'
|
|
45
|
+
hooks: ['context-guardian', 'token-budget-loader', 'tool-output-cacher'],
|
|
46
46
|
default: false,
|
|
47
47
|
requiresPostConfig: false,
|
|
48
48
|
},
|
|
@@ -51,10 +51,10 @@ const OPTIONAL_FEATURES = [
|
|
|
51
51
|
label: 'Happy Engineering Integration',
|
|
52
52
|
description: 'Integration with Happy Coder mobile app for remote session control, checkpoint management, and mobile-optimized responses.',
|
|
53
53
|
commands: ['happy-start'],
|
|
54
|
-
hooks: ['happy-checkpoint-manager'
|
|
54
|
+
hooks: ['happy-checkpoint-manager', 'happy-title-generator', 'happy-mode-detector', 'context-injector'],
|
|
55
55
|
default: false,
|
|
56
56
|
requiresPostConfig: true,
|
|
57
|
-
npmPackage: 'happy-coder',
|
|
57
|
+
npmPackage: 'happy-coder',
|
|
58
58
|
npmInstallPrompt: 'Install Happy Coder CLI globally? (npm i -g happy-coder)',
|
|
59
59
|
},
|
|
60
60
|
{
|
package/src/data/releases.json
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"releases": [
|
|
3
|
+
{
|
|
4
|
+
"version": "1.1.0",
|
|
5
|
+
"date": "2026-01-30",
|
|
6
|
+
"summary": "Feature: Phase 1 of 85 Recommendations - 5 New Hook Templates",
|
|
7
|
+
"highlights": [
|
|
8
|
+
"Added tool-output-cacher.template.js - Caches large outputs, saves ~500 tokens per output",
|
|
9
|
+
"Added token-budget-loader.template.js - Pre-calculates daily budget, ~5K tokens/session savings",
|
|
10
|
+
"Added happy-title-generator.template.js - Auto-generates session titles with issue numbers",
|
|
11
|
+
"Added happy-mode-detector.template.js - Detects Happy daemon environment",
|
|
12
|
+
"Added context-injector.template.js - Injects prior session context for continuity",
|
|
13
|
+
"All hooks now configurable via .claude/config/hooks-config.json",
|
|
14
|
+
"Updated tokenManagement feature to include token-budget-loader and tool-output-cacher",
|
|
15
|
+
"Updated happyMode feature to include all Happy-related hooks"
|
|
16
|
+
],
|
|
17
|
+
"newFeatures": {
|
|
18
|
+
"commands": [],
|
|
19
|
+
"agents": [],
|
|
20
|
+
"skills": [],
|
|
21
|
+
"hooks": [
|
|
22
|
+
{
|
|
23
|
+
"name": "tool-output-cacher",
|
|
24
|
+
"description": "Caches large tool outputs to reduce context consumption"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "token-budget-loader",
|
|
28
|
+
"description": "Pre-calculates daily token budget at session start"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "happy-title-generator",
|
|
32
|
+
"description": "Auto-generates session titles for Happy daemon"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "happy-mode-detector",
|
|
36
|
+
"description": "Detects Happy daemon environment and enables mobile mode"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "context-injector",
|
|
40
|
+
"description": "Injects prior session context for seamless resumption"
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"other": []
|
|
44
|
+
},
|
|
45
|
+
"breaking": [],
|
|
46
|
+
"deprecated": []
|
|
47
|
+
},
|
|
3
48
|
{
|
|
4
49
|
"version": "1.0.16",
|
|
5
50
|
"date": "2026-01-30",
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Injector Hook
|
|
3
|
+
*
|
|
4
|
+
* Injects prior session context for seamless resumption.
|
|
5
|
+
* Loads checkpoints, recent progress, and active features.
|
|
6
|
+
* Enables continuity across multi-day projects.
|
|
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
|
+
recent_completed_tasks: 3, // Number of completed tasks to show
|
|
19
|
+
next_pending_tasks: 2, // Number of pending tasks to show
|
|
20
|
+
active_features_limit: 3, // Number of active features to show
|
|
21
|
+
recent_agents_limit: 5, // Number of recent agents to show
|
|
22
|
+
max_checkpoint_age_hours: 48, // Ignore checkpoints older than this
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Paths
|
|
26
|
+
const CONFIG_PATH = path.join(process.cwd(), '.claude', 'config', 'hooks-config.json');
|
|
27
|
+
const CHECKPOINT_PATH = path.join(process.cwd(), '.claude', 'checkpoints', 'latest.json');
|
|
28
|
+
const FEATURE_TRACKING_PATH = path.join(process.cwd(), '.claude', 'config', 'feature-tracking.json');
|
|
29
|
+
const AGENT_LOG_PATH = path.join(process.cwd(), '.claude', 'logs', 'agent-activity.json');
|
|
30
|
+
const SESSION_MARKER = path.join(process.cwd(), '.claude', 'config', '.context-injected');
|
|
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.context_injector || {}) };
|
|
40
|
+
}
|
|
41
|
+
} catch (e) {
|
|
42
|
+
// Use defaults on error
|
|
43
|
+
}
|
|
44
|
+
return DEFAULT_CONFIG;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if we've already injected context this session
|
|
49
|
+
*/
|
|
50
|
+
function hasInjectedThisSession() {
|
|
51
|
+
try {
|
|
52
|
+
if (fs.existsSync(SESSION_MARKER)) {
|
|
53
|
+
const content = fs.readFileSync(SESSION_MARKER, 'utf8');
|
|
54
|
+
const timestamp = parseInt(content, 10);
|
|
55
|
+
// Session valid for 4 hours
|
|
56
|
+
if (Date.now() - timestamp < 4 * 60 * 60 * 1000) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch (e) {
|
|
61
|
+
// Continue with injection
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Mark session as injected
|
|
68
|
+
*/
|
|
69
|
+
function markSessionInjected() {
|
|
70
|
+
try {
|
|
71
|
+
const dir = path.dirname(SESSION_MARKER);
|
|
72
|
+
if (!fs.existsSync(dir)) {
|
|
73
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
fs.writeFileSync(SESSION_MARKER, Date.now().toString(), 'utf8');
|
|
76
|
+
} catch (e) {
|
|
77
|
+
// Silent failure
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Load latest checkpoint
|
|
83
|
+
*/
|
|
84
|
+
function loadCheckpoint(config) {
|
|
85
|
+
try {
|
|
86
|
+
if (!fs.existsSync(CHECKPOINT_PATH)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const checkpoint = JSON.parse(fs.readFileSync(CHECKPOINT_PATH, 'utf8'));
|
|
91
|
+
|
|
92
|
+
// Check age
|
|
93
|
+
if (checkpoint.created_at) {
|
|
94
|
+
const age = Date.now() - new Date(checkpoint.created_at).getTime();
|
|
95
|
+
const maxAge = config.max_checkpoint_age_hours * 60 * 60 * 1000;
|
|
96
|
+
if (age > maxAge) {
|
|
97
|
+
return null; // Checkpoint too old
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return checkpoint;
|
|
102
|
+
} catch (e) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Load feature tracking
|
|
109
|
+
*/
|
|
110
|
+
function loadFeatureTracking() {
|
|
111
|
+
try {
|
|
112
|
+
if (fs.existsSync(FEATURE_TRACKING_PATH)) {
|
|
113
|
+
return JSON.parse(fs.readFileSync(FEATURE_TRACKING_PATH, 'utf8'));
|
|
114
|
+
}
|
|
115
|
+
} catch (e) {
|
|
116
|
+
// No feature tracking
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Load recent agent activity
|
|
123
|
+
*/
|
|
124
|
+
function loadAgentActivity(config) {
|
|
125
|
+
try {
|
|
126
|
+
if (fs.existsSync(AGENT_LOG_PATH)) {
|
|
127
|
+
const activity = JSON.parse(fs.readFileSync(AGENT_LOG_PATH, 'utf8'));
|
|
128
|
+
// Return most recent agents
|
|
129
|
+
if (Array.isArray(activity)) {
|
|
130
|
+
return activity.slice(-config.recent_agents_limit);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch (e) {
|
|
134
|
+
// No agent activity
|
|
135
|
+
}
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Format task list for display
|
|
141
|
+
*/
|
|
142
|
+
function formatTaskList(tasks, limit, status) {
|
|
143
|
+
if (!tasks || !Array.isArray(tasks)) return '';
|
|
144
|
+
|
|
145
|
+
const filtered = tasks.filter(t => t.status === status).slice(0, limit);
|
|
146
|
+
if (filtered.length === 0) return '';
|
|
147
|
+
|
|
148
|
+
return filtered.map(t => ` - ${t.title || t.name || 'Unknown task'}`).join('\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Format feature list for display
|
|
153
|
+
*/
|
|
154
|
+
function formatFeatureList(features, limit) {
|
|
155
|
+
if (!features || !Array.isArray(features)) return '';
|
|
156
|
+
|
|
157
|
+
const active = features.filter(f => f.status === 'in_progress').slice(0, limit);
|
|
158
|
+
if (active.length === 0) return '';
|
|
159
|
+
|
|
160
|
+
return active.map(f => ` - ${f.name}: ${f.progress || 0}%`).join('\n');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Format agent list for display
|
|
165
|
+
*/
|
|
166
|
+
function formatAgentList(agents) {
|
|
167
|
+
if (!agents || agents.length === 0) return '';
|
|
168
|
+
|
|
169
|
+
return agents.map(a => {
|
|
170
|
+
const name = a.name || a.type || 'agent';
|
|
171
|
+
const status = a.success ? 'completed' : 'failed';
|
|
172
|
+
return ` - ${name}: ${status}`;
|
|
173
|
+
}).join('\n');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Build context message
|
|
178
|
+
*/
|
|
179
|
+
function buildContextMessage(checkpoint, features, agents, config) {
|
|
180
|
+
const sections = [];
|
|
181
|
+
|
|
182
|
+
// Add checkpoint progress
|
|
183
|
+
if (checkpoint) {
|
|
184
|
+
const completed = formatTaskList(checkpoint.tasks, config.recent_completed_tasks, 'completed');
|
|
185
|
+
const pending = formatTaskList(checkpoint.tasks, config.next_pending_tasks, 'pending');
|
|
186
|
+
|
|
187
|
+
if (completed) {
|
|
188
|
+
sections.push(`**Recent Progress:**\n${completed}`);
|
|
189
|
+
}
|
|
190
|
+
if (pending) {
|
|
191
|
+
sections.push(`**Next Tasks:**\n${pending}`);
|
|
192
|
+
}
|
|
193
|
+
if (checkpoint.current_phase) {
|
|
194
|
+
sections.push(`**Current Phase:** ${checkpoint.current_phase}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Add active features
|
|
199
|
+
if (features) {
|
|
200
|
+
const featureList = formatFeatureList(features.features || features, config.active_features_limit);
|
|
201
|
+
if (featureList) {
|
|
202
|
+
sections.push(`**Active Features:**\n${featureList}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Add recent agents
|
|
207
|
+
if (agents && agents.length > 0) {
|
|
208
|
+
const agentList = formatAgentList(agents);
|
|
209
|
+
if (agentList) {
|
|
210
|
+
sections.push(`**Recent Agents:**\n${agentList}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return sections.join('\n\n');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Main hook handler
|
|
219
|
+
*/
|
|
220
|
+
module.exports = async function contextInjector(context) {
|
|
221
|
+
// Always continue - never block
|
|
222
|
+
const approve = () => ({ continue: true });
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
// Check if already injected this session
|
|
226
|
+
if (hasInjectedThisSession()) {
|
|
227
|
+
return approve();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Mark session as injected
|
|
231
|
+
markSessionInjected();
|
|
232
|
+
|
|
233
|
+
const config = loadConfig();
|
|
234
|
+
|
|
235
|
+
// Load context sources
|
|
236
|
+
const checkpoint = loadCheckpoint(config);
|
|
237
|
+
const features = loadFeatureTracking();
|
|
238
|
+
const agents = loadAgentActivity(config);
|
|
239
|
+
|
|
240
|
+
// Check if we have any context to inject
|
|
241
|
+
if (!checkpoint && !features && agents.length === 0) {
|
|
242
|
+
console.log('[context-injector] No prior context found');
|
|
243
|
+
return approve();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Build context message
|
|
247
|
+
const message = buildContextMessage(checkpoint, features, agents, config);
|
|
248
|
+
|
|
249
|
+
if (message) {
|
|
250
|
+
console.log('[context-injector] Session context loaded:');
|
|
251
|
+
console.log('---');
|
|
252
|
+
console.log(message);
|
|
253
|
+
console.log('---');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return approve();
|
|
257
|
+
} catch (error) {
|
|
258
|
+
console.error(`[context-injector] Error: ${error.message}`);
|
|
259
|
+
return approve();
|
|
260
|
+
}
|
|
261
|
+
};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Happy Mode Detector Hook
|
|
3
|
+
*
|
|
4
|
+
* Auto-detects Happy daemon environment and enables mobile-optimized mode.
|
|
5
|
+
* Sets appropriate verbosity and response formatting for mobile clients.
|
|
6
|
+
* Supports multiple detection methods: env var, daemon state, manual config.
|
|
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
|
+
enabled: false, // Manual override
|
|
19
|
+
auto_detect: true, // Attempt auto-detection
|
|
20
|
+
verbosity: 'condensed', // 'verbose', 'condensed', 'compact'
|
|
21
|
+
show_file_stats: true, // Show file operation statistics
|
|
22
|
+
max_grep_results: 5, // Limit grep results for mobile
|
|
23
|
+
checkpoint_interval_minutes: 10, // Auto-checkpoint frequency
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Paths
|
|
27
|
+
const CONFIG_PATH = path.join(process.cwd(), '.claude', 'config', 'hooks-config.json');
|
|
28
|
+
const HAPPY_MODE_PATH = path.join(process.cwd(), '.claude', 'config', 'happy-mode.json');
|
|
29
|
+
const HAPPY_STATE_PATH = path.join(process.env.HOME || process.env.USERPROFILE, '.happy', 'daemon.state.json');
|
|
30
|
+
const SESSION_MARKER = path.join(process.cwd(), '.claude', 'config', '.happy-detected');
|
|
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.happy_mode || {}) };
|
|
40
|
+
}
|
|
41
|
+
} catch (e) {
|
|
42
|
+
// Use defaults on error
|
|
43
|
+
}
|
|
44
|
+
return DEFAULT_CONFIG;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if we've already detected Happy mode this session
|
|
49
|
+
*/
|
|
50
|
+
function hasDetectedThisSession() {
|
|
51
|
+
try {
|
|
52
|
+
if (fs.existsSync(SESSION_MARKER)) {
|
|
53
|
+
const content = fs.readFileSync(SESSION_MARKER, 'utf8');
|
|
54
|
+
const data = JSON.parse(content);
|
|
55
|
+
// Session valid for 4 hours
|
|
56
|
+
if (Date.now() - data.timestamp < 4 * 60 * 60 * 1000) {
|
|
57
|
+
return data;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch (e) {
|
|
61
|
+
// Continue with detection
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Mark session as detected
|
|
68
|
+
*/
|
|
69
|
+
function markSessionDetected(result) {
|
|
70
|
+
try {
|
|
71
|
+
const dir = path.dirname(SESSION_MARKER);
|
|
72
|
+
if (!fs.existsSync(dir)) {
|
|
73
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
fs.writeFileSync(SESSION_MARKER, JSON.stringify({
|
|
76
|
+
timestamp: Date.now(),
|
|
77
|
+
...result,
|
|
78
|
+
}, null, 2), 'utf8');
|
|
79
|
+
} catch (e) {
|
|
80
|
+
// Silent failure
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Detect Happy mode via environment variable
|
|
86
|
+
*/
|
|
87
|
+
function detectViaEnvVar() {
|
|
88
|
+
return process.env.HAPPY_SESSION === 'true';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Detect Happy mode via daemon state file
|
|
93
|
+
*/
|
|
94
|
+
function detectViaDaemonState() {
|
|
95
|
+
try {
|
|
96
|
+
if (fs.existsSync(HAPPY_STATE_PATH)) {
|
|
97
|
+
const state = JSON.parse(fs.readFileSync(HAPPY_STATE_PATH, 'utf8'));
|
|
98
|
+
// Check if daemon is running and has active session
|
|
99
|
+
if (state.running && state.current_session) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} catch (e) {
|
|
104
|
+
// Daemon not running or state invalid
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Detect Happy mode via manual configuration
|
|
111
|
+
*/
|
|
112
|
+
function detectViaManualConfig() {
|
|
113
|
+
try {
|
|
114
|
+
if (fs.existsSync(HAPPY_MODE_PATH)) {
|
|
115
|
+
const config = JSON.parse(fs.readFileSync(HAPPY_MODE_PATH, 'utf8'));
|
|
116
|
+
return config.enabled === true;
|
|
117
|
+
}
|
|
118
|
+
} catch (e) {
|
|
119
|
+
// No manual config
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get detection method description
|
|
126
|
+
*/
|
|
127
|
+
function getDetectionMethod(envVar, daemon, manual) {
|
|
128
|
+
if (manual) return 'manual_config';
|
|
129
|
+
if (envVar) return 'env_var';
|
|
130
|
+
if (daemon) return 'daemon_state';
|
|
131
|
+
return 'none';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Save Happy mode state for other hooks to use
|
|
136
|
+
*/
|
|
137
|
+
function saveHappyModeState(result) {
|
|
138
|
+
try {
|
|
139
|
+
const dir = path.dirname(HAPPY_MODE_PATH);
|
|
140
|
+
if (!fs.existsSync(dir)) {
|
|
141
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
142
|
+
}
|
|
143
|
+
fs.writeFileSync(HAPPY_MODE_PATH, JSON.stringify(result, null, 2), 'utf8');
|
|
144
|
+
} catch (e) {
|
|
145
|
+
// Silent failure
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Main hook handler
|
|
151
|
+
*/
|
|
152
|
+
module.exports = async function happyModeDetector(context) {
|
|
153
|
+
// Always continue - never block
|
|
154
|
+
const approve = () => ({ continue: true });
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
// Check if already detected this session
|
|
158
|
+
const cached = hasDetectedThisSession();
|
|
159
|
+
if (cached) {
|
|
160
|
+
return approve();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const config = loadConfig();
|
|
164
|
+
|
|
165
|
+
// Skip detection if auto-detect is disabled
|
|
166
|
+
if (!config.auto_detect) {
|
|
167
|
+
const result = {
|
|
168
|
+
happy_mode: config.enabled,
|
|
169
|
+
detection_method: config.enabled ? 'manual_override' : 'disabled',
|
|
170
|
+
config: config,
|
|
171
|
+
};
|
|
172
|
+
markSessionDetected(result);
|
|
173
|
+
return approve();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Try all detection methods
|
|
177
|
+
const viaEnvVar = detectViaEnvVar();
|
|
178
|
+
const viaDaemon = detectViaDaemonState();
|
|
179
|
+
const viaManual = detectViaManualConfig();
|
|
180
|
+
|
|
181
|
+
const isHappyMode = viaEnvVar || viaDaemon || viaManual;
|
|
182
|
+
const detectionMethod = getDetectionMethod(viaEnvVar, viaDaemon, viaManual);
|
|
183
|
+
|
|
184
|
+
// Build result object
|
|
185
|
+
const result = {
|
|
186
|
+
happy_mode: isHappyMode,
|
|
187
|
+
detection_method: detectionMethod,
|
|
188
|
+
detected_at: new Date().toISOString(),
|
|
189
|
+
config: {
|
|
190
|
+
verbosity: config.verbosity,
|
|
191
|
+
show_file_stats: config.show_file_stats,
|
|
192
|
+
max_grep_results: config.max_grep_results,
|
|
193
|
+
checkpoint_interval_minutes: config.checkpoint_interval_minutes,
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Save state for other hooks
|
|
198
|
+
saveHappyModeState(result);
|
|
199
|
+
markSessionDetected(result);
|
|
200
|
+
|
|
201
|
+
// Log detection result
|
|
202
|
+
if (isHappyMode) {
|
|
203
|
+
console.log(`[happy-mode-detector] Happy mode ENABLED (via ${detectionMethod})`);
|
|
204
|
+
console.log(`[happy-mode-detector] Verbosity: ${config.verbosity}`);
|
|
205
|
+
} else {
|
|
206
|
+
console.log('[happy-mode-detector] Happy mode not detected');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return approve();
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error(`[happy-mode-detector] Error: ${error.message}`);
|
|
212
|
+
return approve();
|
|
213
|
+
}
|
|
214
|
+
};
|
|
@@ -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
|
+
};
|