@esotech/contextuate 2.0.0 → 2.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/README.md +169 -1
- package/dist/commands/claude.d.ts +21 -0
- package/dist/commands/claude.js +213 -0
- package/dist/commands/context.d.ts +1 -0
- package/dist/commands/create.d.ts +3 -0
- package/dist/commands/index.d.ts +4 -0
- package/dist/commands/init.d.ts +7 -0
- package/dist/commands/init.js +67 -6
- package/dist/commands/install.d.ts +28 -0
- package/dist/commands/install.js +116 -11
- package/dist/commands/monitor.d.ts +55 -0
- package/dist/commands/monitor.js +1007 -0
- package/dist/commands/remove.d.ts +3 -0
- package/dist/commands/run.d.ts +6 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +113 -1
- package/dist/monitor/daemon/circuit-breaker.d.ts +121 -0
- package/dist/monitor/daemon/circuit-breaker.js +552 -0
- package/dist/monitor/daemon/cli.d.ts +8 -0
- package/dist/monitor/daemon/cli.js +82 -0
- package/dist/monitor/daemon/index.d.ts +137 -0
- package/dist/monitor/daemon/index.js +695 -0
- package/dist/monitor/daemon/notifier.d.ts +25 -0
- package/dist/monitor/daemon/notifier.js +98 -0
- package/dist/monitor/daemon/processor.d.ts +89 -0
- package/dist/monitor/daemon/processor.js +455 -0
- package/dist/monitor/daemon/state.d.ts +80 -0
- package/dist/monitor/daemon/state.js +162 -0
- package/dist/monitor/daemon/watcher.d.ts +47 -0
- package/dist/monitor/daemon/watcher.js +171 -0
- package/dist/monitor/daemon/wrapper-manager.d.ts +106 -0
- package/dist/monitor/daemon/wrapper-manager.js +374 -0
- package/dist/monitor/hooks/emit-event.js +652 -0
- package/dist/monitor/persistence/file-store.d.ts +88 -0
- package/dist/monitor/persistence/file-store.js +335 -0
- package/dist/monitor/persistence/index.d.ts +7 -0
- package/dist/monitor/persistence/index.js +10 -0
- package/dist/monitor/server/adapters/redis.d.ts +38 -0
- package/dist/monitor/server/adapters/redis.js +213 -0
- package/dist/monitor/server/adapters/unix-socket.d.ts +33 -0
- package/dist/monitor/server/adapters/unix-socket.js +182 -0
- package/dist/monitor/server/broker.d.ts +135 -0
- package/dist/monitor/server/broker.js +475 -0
- package/dist/monitor/server/cli.d.ts +8 -0
- package/dist/monitor/server/cli.js +98 -0
- package/dist/monitor/server/fastify.d.ts +16 -0
- package/dist/monitor/server/fastify.js +184 -0
- package/dist/monitor/server/index.d.ts +36 -0
- package/dist/monitor/server/index.js +153 -0
- package/dist/monitor/server/websocket.d.ts +80 -0
- package/dist/monitor/server/websocket.js +453 -0
- package/dist/monitor/ui/assets/index-4IssW9On.js +59 -0
- package/dist/monitor/ui/assets/index-vo9hLe5R.css +32 -0
- package/dist/monitor/ui/favicon.png +0 -0
- package/dist/monitor/ui/index.html +14 -0
- package/dist/monitor/ui/logo.png +0 -0
- package/dist/monitor/ui/logo.svg +1 -0
- package/dist/runtime/driver.d.ts +16 -0
- package/dist/runtime/tools.d.ts +10 -0
- package/dist/templates/README.md +33 -7
- package/dist/templates/agents/aegis.md +4 -0
- package/dist/templates/agents/archon.md +13 -22
- package/dist/templates/agents/atlas.md +4 -0
- package/dist/templates/agents/canvas.md +4 -0
- package/dist/templates/agents/chronicle.md +4 -0
- package/dist/templates/agents/chronos.md +4 -0
- package/dist/templates/agents/cipher.md +4 -0
- package/dist/templates/agents/crucible.md +4 -0
- package/dist/templates/agents/echo.md +4 -0
- package/dist/templates/agents/forge.md +4 -0
- package/dist/templates/agents/ledger.md +4 -0
- package/dist/templates/agents/meridian.md +4 -0
- package/dist/templates/agents/nexus.md +4 -0
- package/dist/templates/agents/pythia.md +217 -0
- package/dist/templates/agents/scribe.md +4 -0
- package/dist/templates/agents/sentinel.md +4 -0
- package/dist/templates/agents/{oracle.md → thoth.md} +11 -7
- package/dist/templates/agents/unity.md +4 -0
- package/dist/templates/agents/vox.md +4 -0
- package/dist/templates/agents/weaver.md +4 -0
- package/dist/templates/framework-agents/documentation-expert.md +3 -3
- package/dist/templates/framework-agents/tools-expert.md +8 -8
- package/dist/templates/skills/consult.md +138 -0
- package/dist/templates/skills/orchestrate.md +173 -0
- package/dist/templates/skills/pythia.md +37 -0
- package/dist/templates/standards/agent-roles.md +68 -21
- package/dist/templates/standards/coding-standards.md +9 -26
- package/dist/templates/templates/context.md +17 -2
- package/dist/templates/templates/contextuate.md +21 -28
- package/dist/templates/templates/standards/go.md +167 -0
- package/dist/templates/templates/standards/java.md +167 -0
- package/dist/templates/templates/standards/javascript.md +292 -0
- package/dist/templates/templates/standards/php.md +181 -0
- package/dist/templates/templates/standards/python.md +175 -0
- package/dist/templates/tools/agent-creator.md +252 -0
- package/dist/templates/tools/agent-creator.tool.md +2 -2
- package/dist/templates/tools/quickref.md +216 -0
- package/dist/templates/tools/spawn.md +31 -0
- package/dist/templates/tools/standards-detector.md +301 -0
- package/dist/templates/version.json +1 -1
- package/dist/types/monitor.d.ts +660 -0
- package/dist/types/monitor.js +75 -0
- package/dist/utils/git.d.ts +9 -0
- package/dist/utils/tokens.d.ts +10 -0
- package/package.json +18 -5
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Contextuate Monitor - Hook Event Emitter
|
|
5
|
+
*
|
|
6
|
+
* This script is invoked by Claude Code hooks to emit events
|
|
7
|
+
* to the Contextuate Monitor server.
|
|
8
|
+
*
|
|
9
|
+
* Hook Registration (in ~/.claude/settings.json):
|
|
10
|
+
* {
|
|
11
|
+
* "hooks": {
|
|
12
|
+
* "PreToolUse": [{ "type": "command", "command": "~/.contextuate/hooks/emit-event.js" }],
|
|
13
|
+
* "PostToolUse": [{ "type": "command", "command": "~/.contextuate/hooks/emit-event.js" }],
|
|
14
|
+
* "Notification": [{ "type": "command", "command": "~/.contextuate/hooks/emit-event.js" }],
|
|
15
|
+
* "Stop": [{ "type": "command", "command": "~/.contextuate/hooks/emit-event.js" }],
|
|
16
|
+
* "SubagentStop": [{ "type": "command", "command": "~/.contextuate/hooks/emit-event.js" }]
|
|
17
|
+
* }
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* Usage: echo '{"hook_type":"PreToolUse",...}' | emit-event.js
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const net = require('net');
|
|
26
|
+
const os = require('os');
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
|
|
29
|
+
// Configuration paths
|
|
30
|
+
const MONITOR_DIR = path.join(os.homedir(), '.contextuate', 'monitor');
|
|
31
|
+
const CONFIG_FILE = path.join(MONITOR_DIR, 'config.json');
|
|
32
|
+
const RAW_DIR = path.join(MONITOR_DIR, 'raw');
|
|
33
|
+
const SESSION_CACHE_DIR = '/tmp';
|
|
34
|
+
|
|
35
|
+
// Legacy paths for migration detection
|
|
36
|
+
const LEGACY_CONFIG_FILE = path.join(os.homedir(), '.contextuate', 'monitor.config.json');
|
|
37
|
+
|
|
38
|
+
// Default configuration
|
|
39
|
+
const DEFAULT_CONFIG = {
|
|
40
|
+
mode: 'local',
|
|
41
|
+
socketPath: '/tmp/contextuate-monitor.sock',
|
|
42
|
+
redis: {
|
|
43
|
+
host: 'localhost',
|
|
44
|
+
port: 6379,
|
|
45
|
+
password: null,
|
|
46
|
+
channel: 'contextuate:events'
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Load configuration
|
|
52
|
+
*/
|
|
53
|
+
function loadConfig() {
|
|
54
|
+
try {
|
|
55
|
+
// Try new path first
|
|
56
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
57
|
+
const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
|
58
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
// Ignore config errors, try legacy
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
// Fall back to legacy path
|
|
66
|
+
if (fs.existsSync(LEGACY_CONFIG_FILE)) {
|
|
67
|
+
const content = fs.readFileSync(LEGACY_CONFIG_FILE, 'utf-8');
|
|
68
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
|
|
69
|
+
}
|
|
70
|
+
} catch (err) {
|
|
71
|
+
// Ignore config errors, use defaults
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return DEFAULT_CONFIG;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Extract session ID from Claude's transcript path
|
|
79
|
+
* Transcript paths look like: ~/.claude/projects/{hash}/.claude/transcript_{sessionId}.jsonl
|
|
80
|
+
* The sessionId part is unique per Claude session
|
|
81
|
+
*/
|
|
82
|
+
function extractSessionFromTranscript(transcriptPath) {
|
|
83
|
+
if (!transcriptPath) return null;
|
|
84
|
+
|
|
85
|
+
// Try to extract session ID from transcript filename
|
|
86
|
+
// Format: transcript_{sessionId}.jsonl or similar
|
|
87
|
+
const match = transcriptPath.match(/transcript[_-]?([a-zA-Z0-9-]+)\.jsonl$/);
|
|
88
|
+
if (match) {
|
|
89
|
+
// Hash the full path to get a shorter, consistent ID
|
|
90
|
+
const hash = crypto.createHash('sha256');
|
|
91
|
+
hash.update(transcriptPath);
|
|
92
|
+
return hash.digest('hex').slice(0, 16);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Generate or retrieve session ID
|
|
100
|
+
* Priority:
|
|
101
|
+
* 1. For SubagentStart/SubagentStop: use agent_id (subagent's own ID)
|
|
102
|
+
* 2. Claude's session_id from hook payload (best - unique per Claude session)
|
|
103
|
+
* 3. CLAUDE_SESSION_ID environment variable (if set)
|
|
104
|
+
* 4. Fallback to TTY+cwd based caching
|
|
105
|
+
*/
|
|
106
|
+
function getSessionId(hookPayload) {
|
|
107
|
+
// For subagent events, use agent_id as the session ID
|
|
108
|
+
// The session_id in these events is actually the PARENT session
|
|
109
|
+
if (hookPayload && (hookPayload.hook_event_name === 'SubagentStart' || hookPayload.hook_event_name === 'SubagentStop')) {
|
|
110
|
+
if (hookPayload.agent_id) {
|
|
111
|
+
return hookPayload.agent_id;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Use Claude's session_id directly if provided (this is the real session ID!)
|
|
116
|
+
if (hookPayload && hookPayload.session_id) {
|
|
117
|
+
// Shorten UUID to 16 chars for consistency
|
|
118
|
+
return hookPayload.session_id.replace(/-/g, '').slice(0, 16);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Check for explicit session ID in environment
|
|
122
|
+
if (process.env.CLAUDE_SESSION_ID) {
|
|
123
|
+
return process.env.CLAUDE_SESSION_ID;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Fallback: Use TTY + cwd for session grouping
|
|
127
|
+
const tty = process.env.SSH_TTY || process.env.TTY || '';
|
|
128
|
+
const cwd = process.env.PWD || process.cwd();
|
|
129
|
+
|
|
130
|
+
const keyHash = crypto.createHash('sha256');
|
|
131
|
+
keyHash.update(`${tty}-${cwd}`);
|
|
132
|
+
const cacheKey = keyHash.digest('hex').slice(0, 16);
|
|
133
|
+
|
|
134
|
+
const cacheFile = path.join(SESSION_CACHE_DIR, `contextuate-session-${cacheKey}.id`);
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
if (fs.existsSync(cacheFile)) {
|
|
138
|
+
const cached = fs.readFileSync(cacheFile, 'utf-8').trim();
|
|
139
|
+
if (cached) return cached;
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
// Ignore cache read errors
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Generate new session ID (random, not time-based)
|
|
146
|
+
const sessionId = crypto.randomUUID().replace(/-/g, '').slice(0, 16);
|
|
147
|
+
|
|
148
|
+
// Cache the session ID
|
|
149
|
+
try {
|
|
150
|
+
fs.writeFileSync(cacheFile, sessionId);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
// Ignore cache write errors
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return sessionId;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Get parent session ID for subagent events
|
|
160
|
+
*/
|
|
161
|
+
function getParentSessionId(hookPayload) {
|
|
162
|
+
// For subagent events, the session_id is actually the parent
|
|
163
|
+
if (hookPayload && (hookPayload.hook_event_name === 'SubagentStart' || hookPayload.hook_event_name === 'SubagentStop')) {
|
|
164
|
+
if (hookPayload.session_id) {
|
|
165
|
+
return hookPayload.session_id.replace(/-/g, '').slice(0, 16);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check environment for parent session
|
|
170
|
+
if (process.env.CONTEXTUATE_PARENT_SESSION) {
|
|
171
|
+
return process.env.CONTEXTUATE_PARENT_SESSION;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Get machine ID
|
|
179
|
+
*/
|
|
180
|
+
function getMachineId() {
|
|
181
|
+
return process.env.HOSTNAME || os.hostname() || 'unknown';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get working directory
|
|
186
|
+
*/
|
|
187
|
+
function getWorkingDirectory() {
|
|
188
|
+
return process.env.PWD || process.cwd();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Generate UUID v4
|
|
193
|
+
*/
|
|
194
|
+
function generateUUID() {
|
|
195
|
+
return crypto.randomUUID ? crypto.randomUUID() :
|
|
196
|
+
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
197
|
+
const r = Math.random() * 16 | 0;
|
|
198
|
+
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|
199
|
+
return v.toString(16);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Parse transcript JSONL file to extract thinking blocks and token usage
|
|
205
|
+
* @param {string} transcriptPath - Path to the transcript JSONL file
|
|
206
|
+
* @returns {{ thinkingBlocks: Array, sessionTokenUsage: Object, model: string|null, assistantResponse: string|null }}
|
|
207
|
+
*/
|
|
208
|
+
function parseTranscript(transcriptPath) {
|
|
209
|
+
const result = {
|
|
210
|
+
thinkingBlocks: [],
|
|
211
|
+
sessionTokenUsage: {
|
|
212
|
+
input: 0,
|
|
213
|
+
output: 0,
|
|
214
|
+
cacheRead: 0,
|
|
215
|
+
cacheCreation5m: 0,
|
|
216
|
+
cacheCreation1h: 0
|
|
217
|
+
},
|
|
218
|
+
model: null,
|
|
219
|
+
assistantResponse: null
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const content = fs.readFileSync(transcriptPath, 'utf-8');
|
|
228
|
+
const lines = content.trim().split('\n');
|
|
229
|
+
let lastAssistantTextBlocks = [];
|
|
230
|
+
|
|
231
|
+
for (const line of lines) {
|
|
232
|
+
if (!line.trim()) continue;
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const entry = JSON.parse(line);
|
|
236
|
+
|
|
237
|
+
// Only process assistant messages (which contain thinking and usage)
|
|
238
|
+
if (entry.type !== 'assistant' || !entry.message) continue;
|
|
239
|
+
|
|
240
|
+
const message = entry.message;
|
|
241
|
+
|
|
242
|
+
// Extract model (use the last one seen)
|
|
243
|
+
if (message.model) {
|
|
244
|
+
result.model = message.model;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Extract token usage
|
|
248
|
+
if (message.usage) {
|
|
249
|
+
const usage = message.usage;
|
|
250
|
+
result.sessionTokenUsage.input += usage.input_tokens || 0;
|
|
251
|
+
result.sessionTokenUsage.output += usage.output_tokens || 0;
|
|
252
|
+
result.sessionTokenUsage.cacheRead += usage.cache_read_input_tokens || 0;
|
|
253
|
+
|
|
254
|
+
// Cache creation tokens
|
|
255
|
+
if (usage.cache_creation) {
|
|
256
|
+
result.sessionTokenUsage.cacheCreation5m += usage.cache_creation.ephemeral_5m_input_tokens || 0;
|
|
257
|
+
result.sessionTokenUsage.cacheCreation1h += usage.cache_creation.ephemeral_1h_input_tokens || 0;
|
|
258
|
+
}
|
|
259
|
+
// Also check for cache_creation_input_tokens (aggregate)
|
|
260
|
+
if (usage.cache_creation_input_tokens) {
|
|
261
|
+
// Only add if we haven't already counted via cache_creation
|
|
262
|
+
if (!usage.cache_creation) {
|
|
263
|
+
result.sessionTokenUsage.cacheCreation5m += usage.cache_creation_input_tokens || 0;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Extract thinking blocks and text content from content
|
|
269
|
+
if (message.content && Array.isArray(message.content)) {
|
|
270
|
+
// Reset text blocks for this assistant message
|
|
271
|
+
lastAssistantTextBlocks = [];
|
|
272
|
+
|
|
273
|
+
for (const block of message.content) {
|
|
274
|
+
if (block.type === 'thinking' && block.thinking) {
|
|
275
|
+
result.thinkingBlocks.push({
|
|
276
|
+
content: block.thinking,
|
|
277
|
+
timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(),
|
|
278
|
+
requestId: entry.requestId || undefined
|
|
279
|
+
});
|
|
280
|
+
} else if (block.type === 'text' && block.text) {
|
|
281
|
+
// Collect text blocks from this assistant message
|
|
282
|
+
lastAssistantTextBlocks.push(block.text);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} catch (parseErr) {
|
|
287
|
+
// Skip malformed lines
|
|
288
|
+
if (process.env.CONTEXTUATE_DEBUG) {
|
|
289
|
+
console.error(`[emit-event] Failed to parse transcript line: ${parseErr.message}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Concatenate all text blocks from the last assistant message
|
|
295
|
+
if (lastAssistantTextBlocks.length > 0) {
|
|
296
|
+
result.assistantResponse = lastAssistantTextBlocks.join('');
|
|
297
|
+
}
|
|
298
|
+
} catch (err) {
|
|
299
|
+
if (process.env.CONTEXTUATE_DEBUG) {
|
|
300
|
+
console.error(`[emit-event] Failed to read transcript: ${err.message}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Map hook type to event type
|
|
309
|
+
*/
|
|
310
|
+
function getEventType(hookType, payload) {
|
|
311
|
+
switch (hookType) {
|
|
312
|
+
// Session lifecycle
|
|
313
|
+
case 'SessionStart':
|
|
314
|
+
return 'session_start';
|
|
315
|
+
case 'SessionEnd':
|
|
316
|
+
case 'Stop':
|
|
317
|
+
return 'session_end';
|
|
318
|
+
|
|
319
|
+
// Tool use
|
|
320
|
+
case 'PreToolUse':
|
|
321
|
+
return 'tool_call';
|
|
322
|
+
case 'PostToolUse':
|
|
323
|
+
return 'tool_result';
|
|
324
|
+
case 'PostToolUseFailure':
|
|
325
|
+
return 'tool_error';
|
|
326
|
+
|
|
327
|
+
// Subagent lifecycle
|
|
328
|
+
case 'SubagentStart':
|
|
329
|
+
return 'subagent_start';
|
|
330
|
+
case 'SubagentStop':
|
|
331
|
+
return 'subagent_stop';
|
|
332
|
+
|
|
333
|
+
// Other events
|
|
334
|
+
case 'Notification':
|
|
335
|
+
return 'notification';
|
|
336
|
+
case 'UserPromptSubmit':
|
|
337
|
+
return 'user_prompt';
|
|
338
|
+
case 'PreCompact':
|
|
339
|
+
return 'pre_compact';
|
|
340
|
+
case 'PermissionRequest':
|
|
341
|
+
return 'permission_request';
|
|
342
|
+
|
|
343
|
+
default:
|
|
344
|
+
return 'message';
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Build MonitorEvent from hook payload
|
|
350
|
+
*/
|
|
351
|
+
function buildEvent(hookPayload) {
|
|
352
|
+
// Claude uses hook_event_name, not hook_type
|
|
353
|
+
const hookType = hookPayload.hook_event_name || hookPayload.hook_type || 'Unknown';
|
|
354
|
+
const eventType = getEventType(hookType, hookPayload);
|
|
355
|
+
|
|
356
|
+
// Build event data
|
|
357
|
+
const data = {};
|
|
358
|
+
|
|
359
|
+
if (hookPayload.tool_name) {
|
|
360
|
+
data.toolName = hookPayload.tool_name;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (hookPayload.tool_input !== undefined) {
|
|
364
|
+
data.toolInput = hookPayload.tool_input;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (hookPayload.tool_output !== undefined) {
|
|
368
|
+
data.toolOutput = hookPayload.tool_output;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (hookPayload.message) {
|
|
372
|
+
data.message = hookPayload.message;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// UserPromptSubmit hook sends 'prompt' field (not 'user_prompt')
|
|
376
|
+
if (hookPayload.prompt) {
|
|
377
|
+
data.message = hookPayload.prompt;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (hookPayload.error) {
|
|
381
|
+
data.error = {
|
|
382
|
+
code: hookPayload.error.code || 'UNKNOWN',
|
|
383
|
+
message: hookPayload.error.message || 'Unknown error'
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (hookPayload.token_usage) {
|
|
388
|
+
data.tokenUsage = {
|
|
389
|
+
input: hookPayload.token_usage.input_tokens || 0,
|
|
390
|
+
output: hookPayload.token_usage.output_tokens || 0,
|
|
391
|
+
cacheRead: hookPayload.token_usage.cache_read_tokens,
|
|
392
|
+
cacheWrite: hookPayload.token_usage.cache_write_tokens
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Extract subagent info from various sources
|
|
397
|
+
if (hookPayload.agent_type) {
|
|
398
|
+
// SubagentStart/SubagentStop events have agent_type at top level
|
|
399
|
+
data.subagent = {
|
|
400
|
+
type: hookPayload.agent_type.toLowerCase(),
|
|
401
|
+
agentId: hookPayload.agent_id || undefined
|
|
402
|
+
};
|
|
403
|
+
} else if (hookPayload.subagent) {
|
|
404
|
+
data.subagent = {
|
|
405
|
+
type: (hookPayload.subagent.type || 'unknown').toLowerCase(),
|
|
406
|
+
prompt: hookPayload.subagent.prompt || ''
|
|
407
|
+
};
|
|
408
|
+
} else if (hookPayload.tool_name === 'Task' && hookPayload.tool_input) {
|
|
409
|
+
// For Task tool calls, extract subagent info from tool_input
|
|
410
|
+
data.subagent = {
|
|
411
|
+
type: (hookPayload.tool_input.subagent_type || 'unknown').toLowerCase(),
|
|
412
|
+
prompt: hookPayload.tool_input.prompt || '',
|
|
413
|
+
description: hookPayload.tool_input.description || ''
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// For Stop and SubagentStop events, parse the transcript for thinking and token usage
|
|
418
|
+
if ((hookType === 'Stop' || hookType === 'SubagentStop') && hookPayload.transcript_path) {
|
|
419
|
+
const transcriptData = parseTranscript(hookPayload.transcript_path);
|
|
420
|
+
|
|
421
|
+
// Add transcript path to data
|
|
422
|
+
data.transcriptPath = hookPayload.transcript_path;
|
|
423
|
+
|
|
424
|
+
// Add thinking blocks
|
|
425
|
+
if (transcriptData.thinkingBlocks.length > 0) {
|
|
426
|
+
data.thinkingBlocks = transcriptData.thinkingBlocks;
|
|
427
|
+
// Also set the last thinking as the legacy 'thinking' field
|
|
428
|
+
data.thinking = transcriptData.thinkingBlocks[transcriptData.thinkingBlocks.length - 1].content;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Add session token usage (cumulative)
|
|
432
|
+
if (transcriptData.sessionTokenUsage.input > 0 || transcriptData.sessionTokenUsage.output > 0) {
|
|
433
|
+
data.sessionTokenUsage = transcriptData.sessionTokenUsage;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Add model
|
|
437
|
+
if (transcriptData.model) {
|
|
438
|
+
data.model = transcriptData.model;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Add assistant response
|
|
442
|
+
if (transcriptData.assistantResponse) {
|
|
443
|
+
data.assistantResponse = transcriptData.assistantResponse;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Get parent session ID (for subagent events, this comes from the hook payload)
|
|
448
|
+
const parentSessionId = getParentSessionId(hookPayload);
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
id: generateUUID(),
|
|
452
|
+
timestamp: Date.now(),
|
|
453
|
+
sessionId: getSessionId(hookPayload),
|
|
454
|
+
parentSessionId: parentSessionId,
|
|
455
|
+
machineId: getMachineId(),
|
|
456
|
+
workingDirectory: hookPayload.cwd || getWorkingDirectory(),
|
|
457
|
+
eventType: eventType,
|
|
458
|
+
hookType: hookType,
|
|
459
|
+
data: data
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Write raw event to disk (Layer 1: Resilient capture)
|
|
465
|
+
* This is the PRIMARY data path - events are first persisted to disk
|
|
466
|
+
* before attempting any network notification.
|
|
467
|
+
*/
|
|
468
|
+
async function writeRawEvent(event) {
|
|
469
|
+
try {
|
|
470
|
+
// Create raw directory if it doesn't exist
|
|
471
|
+
await fs.promises.mkdir(RAW_DIR, { recursive: true });
|
|
472
|
+
|
|
473
|
+
// Filename format: {timestamp}-{sessionId}-{eventId}.json
|
|
474
|
+
// This ensures events are sorted by time when listing directory
|
|
475
|
+
const filename = `${event.timestamp}-${event.sessionId}-${event.id}.json`;
|
|
476
|
+
const filepath = path.join(RAW_DIR, filename);
|
|
477
|
+
|
|
478
|
+
// Write event as formatted JSON (helpful for debugging)
|
|
479
|
+
await fs.promises.writeFile(filepath, JSON.stringify(event, null, 2));
|
|
480
|
+
|
|
481
|
+
return filepath;
|
|
482
|
+
} catch (err) {
|
|
483
|
+
// Log error but don't fail - event capture should be resilient
|
|
484
|
+
if (process.env.CONTEXTUATE_DEBUG) {
|
|
485
|
+
console.error('[Hook] Failed to write raw event:', err.message);
|
|
486
|
+
}
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Notify daemon via Unix socket (fire-and-forget)
|
|
493
|
+
*/
|
|
494
|
+
function notifyViaSocket(socketPath, event) {
|
|
495
|
+
return new Promise((resolve, reject) => {
|
|
496
|
+
const client = net.createConnection(socketPath);
|
|
497
|
+
|
|
498
|
+
// Short timeout - this is just a notification
|
|
499
|
+
client.setTimeout(500);
|
|
500
|
+
|
|
501
|
+
client.on('connect', () => {
|
|
502
|
+
client.write(JSON.stringify(event) + '\n');
|
|
503
|
+
client.end();
|
|
504
|
+
resolve();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
client.on('error', (err) => {
|
|
508
|
+
// Don't fail - daemon might not be running
|
|
509
|
+
reject(err);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
client.on('timeout', () => {
|
|
513
|
+
client.destroy();
|
|
514
|
+
reject(new Error('Notification timeout'));
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Publish event to Redis for UI aggregation (fire-and-forget)
|
|
521
|
+
*/
|
|
522
|
+
async function publishToRedis(redisConfig, event) {
|
|
523
|
+
let client = null;
|
|
524
|
+
try {
|
|
525
|
+
// Dynamic require to avoid loading redis when not needed
|
|
526
|
+
const Redis = require('ioredis');
|
|
527
|
+
|
|
528
|
+
client = new Redis({
|
|
529
|
+
host: redisConfig.host,
|
|
530
|
+
port: redisConfig.port,
|
|
531
|
+
password: redisConfig.password || undefined,
|
|
532
|
+
connectTimeout: 500,
|
|
533
|
+
maxRetriesPerRequest: 1,
|
|
534
|
+
retryStrategy: () => null, // Don't retry in hook script (needs to be fast)
|
|
535
|
+
lazyConnect: true,
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// Connect with timeout
|
|
539
|
+
await Promise.race([
|
|
540
|
+
client.connect(),
|
|
541
|
+
new Promise((_, reject) =>
|
|
542
|
+
setTimeout(() => reject(new Error('Connection timeout')), 500)
|
|
543
|
+
),
|
|
544
|
+
]);
|
|
545
|
+
|
|
546
|
+
// Publish event
|
|
547
|
+
await client.publish(redisConfig.channel, JSON.stringify(event));
|
|
548
|
+
|
|
549
|
+
// Log success for debugging
|
|
550
|
+
if (process.env.CONTEXTUATE_DEBUG) {
|
|
551
|
+
console.error(`[Hook] Published to Redis: ${event.eventType}`);
|
|
552
|
+
}
|
|
553
|
+
} catch (err) {
|
|
554
|
+
// Don't fail - Redis might not be available
|
|
555
|
+
throw err;
|
|
556
|
+
} finally {
|
|
557
|
+
// Always disconnect to avoid hanging connections
|
|
558
|
+
if (client) {
|
|
559
|
+
client.disconnect();
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Notify daemon via socket and/or Redis (Layer 2: Real-time notification)
|
|
566
|
+
* This is fire-and-forget - failures here don't affect event capture.
|
|
567
|
+
*/
|
|
568
|
+
async function notifyDaemon(config, event) {
|
|
569
|
+
const promises = [];
|
|
570
|
+
|
|
571
|
+
// Always try local socket notification
|
|
572
|
+
promises.push(
|
|
573
|
+
notifyViaSocket(config.socketPath || '/tmp/contextuate-monitor.sock', event)
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
// If Redis mode, also publish for UI aggregation
|
|
577
|
+
if (config.mode === 'redis' && config.redis) {
|
|
578
|
+
promises.push(publishToRedis(config.redis, event));
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Wait for all but don't fail if any notification fails
|
|
582
|
+
await Promise.allSettled(promises);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Main entry point
|
|
587
|
+
*/
|
|
588
|
+
async function main() {
|
|
589
|
+
// Read hook payload from stdin
|
|
590
|
+
let input = '';
|
|
591
|
+
|
|
592
|
+
// Check if stdin has data
|
|
593
|
+
if (process.stdin.isTTY) {
|
|
594
|
+
// No input, exit
|
|
595
|
+
process.exit(0);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Read all input
|
|
599
|
+
for await (const chunk of process.stdin) {
|
|
600
|
+
input += chunk;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (!input.trim()) {
|
|
604
|
+
process.exit(0);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Parse the hook payload
|
|
608
|
+
let hookPayload;
|
|
609
|
+
try {
|
|
610
|
+
hookPayload = JSON.parse(input);
|
|
611
|
+
} catch (err) {
|
|
612
|
+
console.error('[emit-event] Failed to parse hook payload:', err.message);
|
|
613
|
+
process.exit(1);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Load configuration
|
|
617
|
+
const config = loadConfig();
|
|
618
|
+
|
|
619
|
+
// Debug: Log hook payloads to understand the structure
|
|
620
|
+
if (hookPayload.hook_event_name === 'SubagentStart' || hookPayload.hook_event_name === 'SubagentStop' || hookPayload.hook_event_name === 'UserPromptSubmit') {
|
|
621
|
+
const debugPath = '/tmp/hook-debug.log';
|
|
622
|
+
const debugEntry = `\n=== ${new Date().toISOString()} - ${hookPayload.hook_event_name} ===\n${JSON.stringify(hookPayload, null, 2)}\n`;
|
|
623
|
+
try {
|
|
624
|
+
fs.appendFileSync(debugPath, debugEntry);
|
|
625
|
+
} catch (e) {}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Build the monitor event
|
|
629
|
+
const event = buildEvent(hookPayload);
|
|
630
|
+
|
|
631
|
+
// LAYER 1: Write raw event to disk FIRST (primary data path)
|
|
632
|
+
const rawPath = await writeRawEvent(event);
|
|
633
|
+
if (rawPath && process.env.CONTEXTUATE_DEBUG) {
|
|
634
|
+
console.error('[Hook] Wrote raw event:', rawPath);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// LAYER 2: Try to notify daemon (fire-and-forget, don't block on errors)
|
|
638
|
+
notifyDaemon(config, event).catch(err => {
|
|
639
|
+
if (process.env.CONTEXTUATE_DEBUG) {
|
|
640
|
+
console.error('[Hook] Daemon notification failed (non-fatal):', err.message);
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// Return immediately to Claude - don't wait for notifications
|
|
645
|
+
process.exit(0);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Run
|
|
649
|
+
main().catch((err) => {
|
|
650
|
+
console.error('[emit-event] Error:', err.message);
|
|
651
|
+
process.exit(1);
|
|
652
|
+
});
|