@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.
Files changed (105) hide show
  1. package/README.md +169 -1
  2. package/dist/commands/claude.d.ts +21 -0
  3. package/dist/commands/claude.js +213 -0
  4. package/dist/commands/context.d.ts +1 -0
  5. package/dist/commands/create.d.ts +3 -0
  6. package/dist/commands/index.d.ts +4 -0
  7. package/dist/commands/init.d.ts +7 -0
  8. package/dist/commands/init.js +67 -6
  9. package/dist/commands/install.d.ts +28 -0
  10. package/dist/commands/install.js +116 -11
  11. package/dist/commands/monitor.d.ts +55 -0
  12. package/dist/commands/monitor.js +1007 -0
  13. package/dist/commands/remove.d.ts +3 -0
  14. package/dist/commands/run.d.ts +6 -0
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.js +113 -1
  17. package/dist/monitor/daemon/circuit-breaker.d.ts +121 -0
  18. package/dist/monitor/daemon/circuit-breaker.js +552 -0
  19. package/dist/monitor/daemon/cli.d.ts +8 -0
  20. package/dist/monitor/daemon/cli.js +82 -0
  21. package/dist/monitor/daemon/index.d.ts +137 -0
  22. package/dist/monitor/daemon/index.js +695 -0
  23. package/dist/monitor/daemon/notifier.d.ts +25 -0
  24. package/dist/monitor/daemon/notifier.js +98 -0
  25. package/dist/monitor/daemon/processor.d.ts +89 -0
  26. package/dist/monitor/daemon/processor.js +455 -0
  27. package/dist/monitor/daemon/state.d.ts +80 -0
  28. package/dist/monitor/daemon/state.js +162 -0
  29. package/dist/monitor/daemon/watcher.d.ts +47 -0
  30. package/dist/monitor/daemon/watcher.js +171 -0
  31. package/dist/monitor/daemon/wrapper-manager.d.ts +106 -0
  32. package/dist/monitor/daemon/wrapper-manager.js +374 -0
  33. package/dist/monitor/hooks/emit-event.js +652 -0
  34. package/dist/monitor/persistence/file-store.d.ts +88 -0
  35. package/dist/monitor/persistence/file-store.js +335 -0
  36. package/dist/monitor/persistence/index.d.ts +7 -0
  37. package/dist/monitor/persistence/index.js +10 -0
  38. package/dist/monitor/server/adapters/redis.d.ts +38 -0
  39. package/dist/monitor/server/adapters/redis.js +213 -0
  40. package/dist/monitor/server/adapters/unix-socket.d.ts +33 -0
  41. package/dist/monitor/server/adapters/unix-socket.js +182 -0
  42. package/dist/monitor/server/broker.d.ts +135 -0
  43. package/dist/monitor/server/broker.js +475 -0
  44. package/dist/monitor/server/cli.d.ts +8 -0
  45. package/dist/monitor/server/cli.js +98 -0
  46. package/dist/monitor/server/fastify.d.ts +16 -0
  47. package/dist/monitor/server/fastify.js +184 -0
  48. package/dist/monitor/server/index.d.ts +36 -0
  49. package/dist/monitor/server/index.js +153 -0
  50. package/dist/monitor/server/websocket.d.ts +80 -0
  51. package/dist/monitor/server/websocket.js +453 -0
  52. package/dist/monitor/ui/assets/index-4IssW9On.js +59 -0
  53. package/dist/monitor/ui/assets/index-vo9hLe5R.css +32 -0
  54. package/dist/monitor/ui/favicon.png +0 -0
  55. package/dist/monitor/ui/index.html +14 -0
  56. package/dist/monitor/ui/logo.png +0 -0
  57. package/dist/monitor/ui/logo.svg +1 -0
  58. package/dist/runtime/driver.d.ts +16 -0
  59. package/dist/runtime/tools.d.ts +10 -0
  60. package/dist/templates/README.md +33 -7
  61. package/dist/templates/agents/aegis.md +4 -0
  62. package/dist/templates/agents/archon.md +13 -22
  63. package/dist/templates/agents/atlas.md +4 -0
  64. package/dist/templates/agents/canvas.md +4 -0
  65. package/dist/templates/agents/chronicle.md +4 -0
  66. package/dist/templates/agents/chronos.md +4 -0
  67. package/dist/templates/agents/cipher.md +4 -0
  68. package/dist/templates/agents/crucible.md +4 -0
  69. package/dist/templates/agents/echo.md +4 -0
  70. package/dist/templates/agents/forge.md +4 -0
  71. package/dist/templates/agents/ledger.md +4 -0
  72. package/dist/templates/agents/meridian.md +4 -0
  73. package/dist/templates/agents/nexus.md +4 -0
  74. package/dist/templates/agents/pythia.md +217 -0
  75. package/dist/templates/agents/scribe.md +4 -0
  76. package/dist/templates/agents/sentinel.md +4 -0
  77. package/dist/templates/agents/{oracle.md → thoth.md} +11 -7
  78. package/dist/templates/agents/unity.md +4 -0
  79. package/dist/templates/agents/vox.md +4 -0
  80. package/dist/templates/agents/weaver.md +4 -0
  81. package/dist/templates/framework-agents/documentation-expert.md +3 -3
  82. package/dist/templates/framework-agents/tools-expert.md +8 -8
  83. package/dist/templates/skills/consult.md +138 -0
  84. package/dist/templates/skills/orchestrate.md +173 -0
  85. package/dist/templates/skills/pythia.md +37 -0
  86. package/dist/templates/standards/agent-roles.md +68 -21
  87. package/dist/templates/standards/coding-standards.md +9 -26
  88. package/dist/templates/templates/context.md +17 -2
  89. package/dist/templates/templates/contextuate.md +21 -28
  90. package/dist/templates/templates/standards/go.md +167 -0
  91. package/dist/templates/templates/standards/java.md +167 -0
  92. package/dist/templates/templates/standards/javascript.md +292 -0
  93. package/dist/templates/templates/standards/php.md +181 -0
  94. package/dist/templates/templates/standards/python.md +175 -0
  95. package/dist/templates/tools/agent-creator.md +252 -0
  96. package/dist/templates/tools/agent-creator.tool.md +2 -2
  97. package/dist/templates/tools/quickref.md +216 -0
  98. package/dist/templates/tools/spawn.md +31 -0
  99. package/dist/templates/tools/standards-detector.md +301 -0
  100. package/dist/templates/version.json +1 -1
  101. package/dist/types/monitor.d.ts +660 -0
  102. package/dist/types/monitor.js +75 -0
  103. package/dist/utils/git.d.ts +9 -0
  104. package/dist/utils/tokens.d.ts +10 -0
  105. 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
+ });