@rulecatch/ai-pooler 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +232 -0
- package/dist/cli.js +1338 -0
- package/dist/cli.js.map +1 -0
- package/dist/flush.js +1114 -0
- package/dist/flush.js.map +1 -0
- package/dist/index.cjs +1278 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +625 -0
- package/dist/index.js +1240 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/templates/rulecatch-track.sh +549 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1240 @@
|
|
|
1
|
+
import { createDecipheriv, pbkdf2Sync, randomBytes, createCipheriv, createHash } from 'crypto';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Rulecatch AI - MCP Development Analytics Types
|
|
6
|
+
*
|
|
7
|
+
* Event types and interfaces for tracking AI-assisted development metrics.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Pricing table for cost calculation
|
|
11
|
+
* Source: Official pricing pages as of January 2026
|
|
12
|
+
*/
|
|
13
|
+
const MODEL_PRICING = {
|
|
14
|
+
// Claude 4 series (January 2026 pricing)
|
|
15
|
+
'claude-opus-4-5': {
|
|
16
|
+
inputPer1M: 15.0,
|
|
17
|
+
outputPer1M: 75.0,
|
|
18
|
+
cacheReadPer1M: 1.5,
|
|
19
|
+
cacheWritePer1M: 18.75,
|
|
20
|
+
thinkingPer1M: 75.0,
|
|
21
|
+
},
|
|
22
|
+
'claude-sonnet-4': {
|
|
23
|
+
inputPer1M: 3.0,
|
|
24
|
+
outputPer1M: 15.0,
|
|
25
|
+
cacheReadPer1M: 0.3,
|
|
26
|
+
cacheWritePer1M: 3.75,
|
|
27
|
+
thinkingPer1M: 15.0,
|
|
28
|
+
},
|
|
29
|
+
// Claude 3.5 series
|
|
30
|
+
'claude-3.5-sonnet': {
|
|
31
|
+
inputPer1M: 3.0,
|
|
32
|
+
outputPer1M: 15.0,
|
|
33
|
+
cacheReadPer1M: 0.3,
|
|
34
|
+
cacheWritePer1M: 3.75,
|
|
35
|
+
},
|
|
36
|
+
'claude-3.5-haiku': {
|
|
37
|
+
inputPer1M: 0.8,
|
|
38
|
+
outputPer1M: 4.0,
|
|
39
|
+
cacheReadPer1M: 0.08,
|
|
40
|
+
cacheWritePer1M: 1.0,
|
|
41
|
+
},
|
|
42
|
+
// Claude 3 series
|
|
43
|
+
'claude-3-opus': {
|
|
44
|
+
inputPer1M: 15.0,
|
|
45
|
+
outputPer1M: 75.0,
|
|
46
|
+
cacheReadPer1M: 1.5,
|
|
47
|
+
cacheWritePer1M: 18.75,
|
|
48
|
+
},
|
|
49
|
+
'claude-3-sonnet': { inputPer1M: 3.0, outputPer1M: 15.0 },
|
|
50
|
+
'claude-3-haiku': { inputPer1M: 0.25, outputPer1M: 1.25 },
|
|
51
|
+
// GPT series
|
|
52
|
+
'gpt-4': { inputPer1M: 30.0, outputPer1M: 60.0 },
|
|
53
|
+
'gpt-4-turbo': { inputPer1M: 10.0, outputPer1M: 30.0 },
|
|
54
|
+
'gpt-4o': { inputPer1M: 2.5, outputPer1M: 10.0 },
|
|
55
|
+
'gpt-4o-mini': { inputPer1M: 0.15, outputPer1M: 0.6 },
|
|
56
|
+
o1: { inputPer1M: 15.0, outputPer1M: 60.0 },
|
|
57
|
+
'o1-mini': { inputPer1M: 3.0, outputPer1M: 12.0 },
|
|
58
|
+
'o1-pro': { inputPer1M: 150.0, outputPer1M: 600.0 },
|
|
59
|
+
// Gemini
|
|
60
|
+
'gemini-2.0-flash': { inputPer1M: 0.1, outputPer1M: 0.4 },
|
|
61
|
+
'gemini-1.5-pro': { inputPer1M: 1.25, outputPer1M: 5.0 },
|
|
62
|
+
'gemini-1.5-flash': { inputPer1M: 0.075, outputPer1M: 0.3 },
|
|
63
|
+
// Fallback
|
|
64
|
+
unknown: { inputPer1M: 5.0, outputPer1M: 15.0 },
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Calculate cost from token usage with caching support
|
|
68
|
+
*/
|
|
69
|
+
function calculateCost(model, inputTokens, outputTokens, options) {
|
|
70
|
+
const pricing = MODEL_PRICING[model] || MODEL_PRICING.unknown;
|
|
71
|
+
// Base input/output costs
|
|
72
|
+
let inputCost = (inputTokens / 1_000_000) * pricing.inputPer1M;
|
|
73
|
+
const outputCost = (outputTokens / 1_000_000) * pricing.outputPer1M;
|
|
74
|
+
let totalCost = inputCost + outputCost;
|
|
75
|
+
// Cache costs (reduces input cost)
|
|
76
|
+
if (options?.cacheReadTokens && pricing.cacheReadPer1M) {
|
|
77
|
+
const cacheReadCost = (options.cacheReadTokens / 1_000_000) * pricing.cacheReadPer1M;
|
|
78
|
+
// Cache reads replace regular input cost for those tokens
|
|
79
|
+
inputCost -= (options.cacheReadTokens / 1_000_000) * pricing.inputPer1M;
|
|
80
|
+
totalCost = inputCost + outputCost + cacheReadCost;
|
|
81
|
+
}
|
|
82
|
+
if (options?.cacheWriteTokens && pricing.cacheWritePer1M) {
|
|
83
|
+
const cacheWriteCost = (options.cacheWriteTokens / 1_000_000) * pricing.cacheWritePer1M;
|
|
84
|
+
totalCost += cacheWriteCost;
|
|
85
|
+
}
|
|
86
|
+
// Thinking tokens (additional cost)
|
|
87
|
+
if (options?.thinkingTokens && pricing.thinkingPer1M) {
|
|
88
|
+
const thinkingCost = (options.thinkingTokens / 1_000_000) * pricing.thinkingPer1M;
|
|
89
|
+
totalCost += thinkingCost;
|
|
90
|
+
}
|
|
91
|
+
return Math.round(totalCost * 10000) / 10000; // 4 decimal places
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Estimate tokens from character count
|
|
95
|
+
* Rough approximation: ~4 chars per token for English
|
|
96
|
+
*/
|
|
97
|
+
function estimateTokens(charCount) {
|
|
98
|
+
return Math.ceil(charCount / 4);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Format duration in human-readable form
|
|
102
|
+
*/
|
|
103
|
+
function formatDuration(seconds) {
|
|
104
|
+
const hours = Math.floor(seconds / 3600);
|
|
105
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
106
|
+
const secs = Math.floor(seconds % 60);
|
|
107
|
+
if (hours > 0) {
|
|
108
|
+
return `${hours}h ${minutes}m ${secs}s`;
|
|
109
|
+
}
|
|
110
|
+
if (minutes > 0) {
|
|
111
|
+
return `${minutes}m ${secs}s`;
|
|
112
|
+
}
|
|
113
|
+
return `${secs}s`;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Format cost in USD
|
|
117
|
+
*/
|
|
118
|
+
function formatCost(cost) {
|
|
119
|
+
if (cost < 0.01) {
|
|
120
|
+
return `$${cost.toFixed(4)}`;
|
|
121
|
+
}
|
|
122
|
+
if (cost < 1) {
|
|
123
|
+
return `$${cost.toFixed(3)}`;
|
|
124
|
+
}
|
|
125
|
+
return `$${cost.toFixed(2)}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Client-side encryption for PII (Zero-Knowledge Architecture)
|
|
130
|
+
*
|
|
131
|
+
* All PII (emails, usernames, file paths) is encrypted on the client
|
|
132
|
+
* before being sent to Rulecatch API. We never see plaintext PII.
|
|
133
|
+
*
|
|
134
|
+
* The user's privacy key is derived from their password or a separate
|
|
135
|
+
* key they set during setup. Without this key, the data is unreadable.
|
|
136
|
+
*/
|
|
137
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
138
|
+
const KEY_LENGTH = 32;
|
|
139
|
+
const IV_LENGTH = 16;
|
|
140
|
+
const PBKDF2_ITERATIONS = 100000;
|
|
141
|
+
/**
|
|
142
|
+
* Derive an encryption key from a password/passphrase
|
|
143
|
+
* Uses PBKDF2 with 100k iterations for brute-force resistance
|
|
144
|
+
*/
|
|
145
|
+
function deriveKey(password, salt) {
|
|
146
|
+
return pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256');
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Generate a random salt for new users
|
|
150
|
+
*/
|
|
151
|
+
function generateSalt() {
|
|
152
|
+
return randomBytes(32).toString('base64');
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Encrypt a PII field (email, username, file path, etc.)
|
|
156
|
+
* Returns ciphertext + IV + auth tag for storage
|
|
157
|
+
*/
|
|
158
|
+
function encryptPII(plaintext, key) {
|
|
159
|
+
if (!plaintext) {
|
|
160
|
+
return { ciphertext: '', iv: '', tag: '' };
|
|
161
|
+
}
|
|
162
|
+
const iv = randomBytes(IV_LENGTH);
|
|
163
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
164
|
+
const encrypted = Buffer.concat([
|
|
165
|
+
cipher.update(plaintext, 'utf8'),
|
|
166
|
+
cipher.final(),
|
|
167
|
+
]);
|
|
168
|
+
const tag = cipher.getAuthTag();
|
|
169
|
+
return {
|
|
170
|
+
ciphertext: encrypted.toString('base64'),
|
|
171
|
+
iv: iv.toString('base64'),
|
|
172
|
+
tag: tag.toString('base64'),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Decrypt a PII field
|
|
177
|
+
* Used client-side in the dashboard to show actual values
|
|
178
|
+
*/
|
|
179
|
+
function decryptPII(encrypted, key) {
|
|
180
|
+
if (!encrypted.ciphertext) {
|
|
181
|
+
return '';
|
|
182
|
+
}
|
|
183
|
+
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(encrypted.iv, 'base64'));
|
|
184
|
+
decipher.setAuthTag(Buffer.from(encrypted.tag, 'base64'));
|
|
185
|
+
const decrypted = Buffer.concat([
|
|
186
|
+
decipher.update(Buffer.from(encrypted.ciphertext, 'base64')),
|
|
187
|
+
decipher.final(),
|
|
188
|
+
]);
|
|
189
|
+
return decrypted.toString('utf8');
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Create a one-way hash for indexing/grouping
|
|
193
|
+
* Cannot be reversed, but same input = same hash (for deduplication)
|
|
194
|
+
*
|
|
195
|
+
* We use a truncated hash (16 chars) to prevent rainbow table attacks
|
|
196
|
+
* while still allowing grouping by the same identifier.
|
|
197
|
+
*/
|
|
198
|
+
function hashForIndex(plaintext, salt) {
|
|
199
|
+
if (!plaintext) {
|
|
200
|
+
return '';
|
|
201
|
+
}
|
|
202
|
+
return createHash('sha256')
|
|
203
|
+
.update(plaintext + salt)
|
|
204
|
+
.digest('hex')
|
|
205
|
+
.slice(0, 16);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Encrypt all PII fields in an event payload
|
|
209
|
+
* Non-PII fields are passed through unchanged
|
|
210
|
+
*/
|
|
211
|
+
function encryptEventPII(event, key, salt) {
|
|
212
|
+
const piiFields = [
|
|
213
|
+
'accountEmail',
|
|
214
|
+
'gitEmail',
|
|
215
|
+
'gitUsername',
|
|
216
|
+
'filePath',
|
|
217
|
+
'cwd',
|
|
218
|
+
'projectId',
|
|
219
|
+
];
|
|
220
|
+
const arrayPiiFields = ['filesModified'];
|
|
221
|
+
const result = { ...event };
|
|
222
|
+
// Encrypt single PII fields
|
|
223
|
+
for (const field of piiFields) {
|
|
224
|
+
const value = event[field];
|
|
225
|
+
if (typeof value === 'string' && value) {
|
|
226
|
+
const encrypted = encryptPII(value, key);
|
|
227
|
+
result[`${field}_encrypted`] = encrypted.ciphertext;
|
|
228
|
+
result[`${field}_iv`] = encrypted.iv;
|
|
229
|
+
result[`${field}_tag`] = encrypted.tag;
|
|
230
|
+
result[`${field}_hash`] = hashForIndex(value, salt);
|
|
231
|
+
delete result[field]; // Remove plaintext
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Encrypt array PII fields
|
|
235
|
+
for (const field of arrayPiiFields) {
|
|
236
|
+
const value = event[field];
|
|
237
|
+
if (Array.isArray(value)) {
|
|
238
|
+
const encryptedArray = value.map((item) => {
|
|
239
|
+
if (typeof item === 'string') {
|
|
240
|
+
const encrypted = encryptPII(item, key);
|
|
241
|
+
return {
|
|
242
|
+
encrypted: encrypted.ciphertext,
|
|
243
|
+
iv: encrypted.iv,
|
|
244
|
+
tag: encrypted.tag,
|
|
245
|
+
hash: hashForIndex(item, salt),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
return item;
|
|
249
|
+
});
|
|
250
|
+
result[`${field}_encrypted`] = encryptedArray;
|
|
251
|
+
result[`${field}_hashes`] = value.map((item) => typeof item === 'string' ? hashForIndex(item, salt) : '');
|
|
252
|
+
delete result[field]; // Remove plaintext
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Decrypt all PII fields in an event payload
|
|
259
|
+
* Used in the dashboard to display actual values
|
|
260
|
+
*/
|
|
261
|
+
function decryptEventPII(event, key) {
|
|
262
|
+
const piiFields = [
|
|
263
|
+
'accountEmail',
|
|
264
|
+
'gitEmail',
|
|
265
|
+
'gitUsername',
|
|
266
|
+
'filePath',
|
|
267
|
+
'cwd',
|
|
268
|
+
'projectId',
|
|
269
|
+
];
|
|
270
|
+
const arrayPiiFields = ['filesModified'];
|
|
271
|
+
const result = { ...event };
|
|
272
|
+
// Decrypt single PII fields
|
|
273
|
+
for (const field of piiFields) {
|
|
274
|
+
const ciphertext = event[`${field}_encrypted`];
|
|
275
|
+
const iv = event[`${field}_iv`];
|
|
276
|
+
const tag = event[`${field}_tag`];
|
|
277
|
+
if (typeof ciphertext === 'string' &&
|
|
278
|
+
typeof iv === 'string' &&
|
|
279
|
+
typeof tag === 'string') {
|
|
280
|
+
try {
|
|
281
|
+
result[field] = decryptPII({ ciphertext, iv, tag }, key);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
result[field] = '[decryption failed]';
|
|
285
|
+
}
|
|
286
|
+
// Clean up encrypted fields
|
|
287
|
+
delete result[`${field}_encrypted`];
|
|
288
|
+
delete result[`${field}_iv`];
|
|
289
|
+
delete result[`${field}_tag`];
|
|
290
|
+
delete result[`${field}_hash`];
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// Decrypt array PII fields
|
|
294
|
+
for (const field of arrayPiiFields) {
|
|
295
|
+
const encryptedArray = event[`${field}_encrypted`];
|
|
296
|
+
if (Array.isArray(encryptedArray)) {
|
|
297
|
+
result[field] = encryptedArray.map((item) => {
|
|
298
|
+
if (item &&
|
|
299
|
+
typeof item === 'object' &&
|
|
300
|
+
'encrypted' in item &&
|
|
301
|
+
'iv' in item &&
|
|
302
|
+
'tag' in item) {
|
|
303
|
+
try {
|
|
304
|
+
return decryptPII({
|
|
305
|
+
ciphertext: item.encrypted,
|
|
306
|
+
iv: item.iv,
|
|
307
|
+
tag: item.tag,
|
|
308
|
+
}, key);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return '[decryption failed]';
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return item;
|
|
315
|
+
});
|
|
316
|
+
delete result[`${field}_encrypted`];
|
|
317
|
+
delete result[`${field}_hashes`];
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return result;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Verify a privacy key is correct by attempting to decrypt a test value
|
|
324
|
+
*/
|
|
325
|
+
function verifyPrivacyKey(testCiphertext, expectedPlaintext, key) {
|
|
326
|
+
try {
|
|
327
|
+
const decrypted = decryptPII(testCiphertext, key);
|
|
328
|
+
return decrypted === expectedPlaintext;
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Git Context Collection
|
|
337
|
+
*
|
|
338
|
+
* Automatically collects git information from the current repository.
|
|
339
|
+
* This provides context about the development environment to track
|
|
340
|
+
* which project/branch/commit is being worked on.
|
|
341
|
+
*/
|
|
342
|
+
/**
|
|
343
|
+
* Execute a git command and return the output
|
|
344
|
+
*/
|
|
345
|
+
function execGit(args, cwd) {
|
|
346
|
+
try {
|
|
347
|
+
const result = execSync(`git ${args}`, {
|
|
348
|
+
encoding: 'utf-8',
|
|
349
|
+
cwd: cwd || process.cwd(),
|
|
350
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
351
|
+
});
|
|
352
|
+
return result.trim();
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Check if we're in a git repository
|
|
360
|
+
*/
|
|
361
|
+
function isGitRepo(cwd) {
|
|
362
|
+
return execGit('rev-parse --is-inside-work-tree', cwd) === 'true';
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Get the root directory of the git repository
|
|
366
|
+
*/
|
|
367
|
+
function getGitRoot(cwd) {
|
|
368
|
+
return execGit('rev-parse --show-toplevel', cwd);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Collect git context from the current directory
|
|
372
|
+
*/
|
|
373
|
+
function collectGitContext(cwd) {
|
|
374
|
+
const context = {};
|
|
375
|
+
if (!isGitRepo(cwd)) {
|
|
376
|
+
return context;
|
|
377
|
+
}
|
|
378
|
+
// Root directory
|
|
379
|
+
context.rootDir = getGitRoot(cwd) || undefined;
|
|
380
|
+
// User info
|
|
381
|
+
context.username = execGit('config user.name', cwd) || undefined;
|
|
382
|
+
context.email = execGit('config user.email', cwd) || undefined;
|
|
383
|
+
// Repository URL
|
|
384
|
+
const remoteUrl = execGit('config --get remote.origin.url', cwd);
|
|
385
|
+
if (remoteUrl) {
|
|
386
|
+
context.repoUrl = remoteUrl;
|
|
387
|
+
context.repoName = extractRepoName(remoteUrl);
|
|
388
|
+
}
|
|
389
|
+
// Branch
|
|
390
|
+
context.branch = execGit('rev-parse --abbrev-ref HEAD', cwd) || undefined;
|
|
391
|
+
// Commit info
|
|
392
|
+
context.commit = execGit('rev-parse --short HEAD', cwd) || undefined;
|
|
393
|
+
context.commitFull = execGit('rev-parse HEAD', cwd) || undefined;
|
|
394
|
+
context.commitMessage = execGit('log -1 --format=%s', cwd) || undefined;
|
|
395
|
+
context.commitAuthor = execGit('log -1 --format=%an', cwd) || undefined;
|
|
396
|
+
context.commitTimestamp = execGit('log -1 --format=%cI', cwd) || undefined;
|
|
397
|
+
// Dirty state
|
|
398
|
+
const status = execGit('status --porcelain', cwd);
|
|
399
|
+
if (status !== null) {
|
|
400
|
+
const files = status.split('\n').filter(line => line.trim().length > 0);
|
|
401
|
+
context.isDirty = files.length > 0;
|
|
402
|
+
context.uncommittedFiles = files.length;
|
|
403
|
+
}
|
|
404
|
+
return context;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Extract repository name from git URL
|
|
408
|
+
*/
|
|
409
|
+
function extractRepoName(url) {
|
|
410
|
+
// Handle SSH URLs: git@github.com:user/repo.git
|
|
411
|
+
const sshMatch = url.match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
412
|
+
if (sshMatch) {
|
|
413
|
+
return sshMatch[1];
|
|
414
|
+
}
|
|
415
|
+
// Handle HTTPS URLs: https://github.com/user/repo.git
|
|
416
|
+
const httpsMatch = url.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
417
|
+
if (httpsMatch) {
|
|
418
|
+
return httpsMatch[1];
|
|
419
|
+
}
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Watch for git changes (branch switches, commits, etc.)
|
|
424
|
+
* Returns a cleanup function to stop watching
|
|
425
|
+
*/
|
|
426
|
+
function watchGitChanges(callback, options) {
|
|
427
|
+
const cwd = options?.cwd;
|
|
428
|
+
const pollInterval = options?.pollInterval || 5000;
|
|
429
|
+
let lastCommit;
|
|
430
|
+
let lastBranch;
|
|
431
|
+
const check = () => {
|
|
432
|
+
if (!isGitRepo(cwd))
|
|
433
|
+
return;
|
|
434
|
+
const currentCommit = execGit('rev-parse HEAD', cwd) || undefined;
|
|
435
|
+
const currentBranch = execGit('rev-parse --abbrev-ref HEAD', cwd) || undefined;
|
|
436
|
+
// Only trigger callback if something changed
|
|
437
|
+
if (currentCommit !== lastCommit || currentBranch !== lastBranch) {
|
|
438
|
+
lastCommit = currentCommit;
|
|
439
|
+
lastBranch = currentBranch;
|
|
440
|
+
callback(collectGitContext(cwd));
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
// Initial check
|
|
444
|
+
check();
|
|
445
|
+
// Poll for changes
|
|
446
|
+
const intervalId = setInterval(check, pollInterval);
|
|
447
|
+
// Return cleanup function
|
|
448
|
+
return () => {
|
|
449
|
+
clearInterval(intervalId);
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Get a summary string for the current git context
|
|
454
|
+
*/
|
|
455
|
+
function getGitSummary(context) {
|
|
456
|
+
const parts = [];
|
|
457
|
+
if (context.repoName) {
|
|
458
|
+
parts.push(context.repoName);
|
|
459
|
+
}
|
|
460
|
+
if (context.branch) {
|
|
461
|
+
parts.push(`@${context.branch}`);
|
|
462
|
+
}
|
|
463
|
+
if (context.commit) {
|
|
464
|
+
parts.push(`(${context.commit}${context.isDirty ? '*' : ''})`);
|
|
465
|
+
}
|
|
466
|
+
return parts.join(' ') || 'unknown';
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* @rulecatch/ai-pooler - AI Development Analytics Pooler
|
|
471
|
+
*
|
|
472
|
+
* Production-grade tracking for AI-assisted development including:
|
|
473
|
+
* - Token usage with prompt caching and thinking tokens
|
|
474
|
+
* - Active time tracking (coding vs idle)
|
|
475
|
+
* - Per-file and per-language cost breakdown
|
|
476
|
+
* - Conversation turns and user interventions
|
|
477
|
+
* - Response latency metrics
|
|
478
|
+
*/
|
|
479
|
+
/**
|
|
480
|
+
* Event buffer for batching
|
|
481
|
+
*/
|
|
482
|
+
const eventBuffer = [];
|
|
483
|
+
let flushTimeout = null;
|
|
484
|
+
/**
|
|
485
|
+
* Current git context
|
|
486
|
+
*/
|
|
487
|
+
let gitContext = {};
|
|
488
|
+
let stopGitWatcher = null;
|
|
489
|
+
/**
|
|
490
|
+
* Current session state with enhanced tracking
|
|
491
|
+
*/
|
|
492
|
+
let currentSession = null;
|
|
493
|
+
/**
|
|
494
|
+
* Configuration
|
|
495
|
+
*/
|
|
496
|
+
let config = null;
|
|
497
|
+
/**
|
|
498
|
+
* Initialize the Rulecatch AI MCP server
|
|
499
|
+
*/
|
|
500
|
+
function init(options) {
|
|
501
|
+
config = {
|
|
502
|
+
endpoint: 'https://api.rulecatch.ai/ai/ingest',
|
|
503
|
+
batchSize: 10,
|
|
504
|
+
flushInterval: 30000,
|
|
505
|
+
includeFilePaths: true,
|
|
506
|
+
trackActiveTime: true,
|
|
507
|
+
trackGitContext: true,
|
|
508
|
+
idleTimeout: 5 * 60 * 1000, // 5 minutes
|
|
509
|
+
debug: false,
|
|
510
|
+
...options,
|
|
511
|
+
};
|
|
512
|
+
// Collect initial git context
|
|
513
|
+
if (config.trackGitContext !== false) {
|
|
514
|
+
gitContext = collectGitContext(config.cwd);
|
|
515
|
+
if (config.debug) {
|
|
516
|
+
console.log('[rulecatch-ai] Git context:', getGitSummary(gitContext));
|
|
517
|
+
}
|
|
518
|
+
// Watch for git changes (branch switches, commits)
|
|
519
|
+
stopGitWatcher = watchGitChanges((newContext) => {
|
|
520
|
+
gitContext = newContext;
|
|
521
|
+
if (config?.debug) {
|
|
522
|
+
console.log('[rulecatch-ai] Git context updated:', getGitSummary(newContext));
|
|
523
|
+
}
|
|
524
|
+
}, { cwd: config.cwd, pollInterval: 10000 });
|
|
525
|
+
}
|
|
526
|
+
if (config.debug) {
|
|
527
|
+
console.log('[rulecatch-ai] Initialized with config:', {
|
|
528
|
+
projectId: config.projectId,
|
|
529
|
+
endpoint: config.endpoint,
|
|
530
|
+
git: getGitSummary(gitContext),
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
// Start session automatically
|
|
534
|
+
startSession();
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Start a new development session
|
|
538
|
+
*/
|
|
539
|
+
function startSession() {
|
|
540
|
+
const sessionId = generateSessionId();
|
|
541
|
+
const now = new Date().toISOString();
|
|
542
|
+
currentSession = {
|
|
543
|
+
id: sessionId,
|
|
544
|
+
startTime: now,
|
|
545
|
+
events: [],
|
|
546
|
+
turnNumber: 0,
|
|
547
|
+
lastActivityTime: Date.now(),
|
|
548
|
+
activeTimeMs: 0,
|
|
549
|
+
fileCosts: new Map(),
|
|
550
|
+
languageCosts: new Map(),
|
|
551
|
+
fileChanges: new Map(),
|
|
552
|
+
toolCalls: new Map(),
|
|
553
|
+
responseTimes: [],
|
|
554
|
+
timeToFirstTokens: [],
|
|
555
|
+
contextUsages: [],
|
|
556
|
+
ruleViolations: new Map(),
|
|
557
|
+
violationsByCategory: new Map(),
|
|
558
|
+
violationsBySeverity: new Map(),
|
|
559
|
+
violationsBySource: new Map(),
|
|
560
|
+
correctionsCount: 0,
|
|
561
|
+
};
|
|
562
|
+
track({
|
|
563
|
+
type: 'session_start',
|
|
564
|
+
timestamp: now,
|
|
565
|
+
sessionId,
|
|
566
|
+
projectId: config?.projectId || 'unknown',
|
|
567
|
+
});
|
|
568
|
+
return sessionId;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* End the current session
|
|
572
|
+
*/
|
|
573
|
+
function endSession() {
|
|
574
|
+
if (!currentSession)
|
|
575
|
+
return null;
|
|
576
|
+
const now = new Date().toISOString();
|
|
577
|
+
// Update active time for final segment
|
|
578
|
+
updateActiveTime();
|
|
579
|
+
track({
|
|
580
|
+
type: 'session_end',
|
|
581
|
+
timestamp: now,
|
|
582
|
+
sessionId: currentSession.id,
|
|
583
|
+
projectId: config?.projectId || 'unknown',
|
|
584
|
+
});
|
|
585
|
+
const metrics = calculateSessionMetrics(currentSession.events);
|
|
586
|
+
flush(); // Ensure all events are sent
|
|
587
|
+
// Stop git watcher
|
|
588
|
+
if (stopGitWatcher) {
|
|
589
|
+
stopGitWatcher();
|
|
590
|
+
stopGitWatcher = null;
|
|
591
|
+
}
|
|
592
|
+
currentSession = null;
|
|
593
|
+
return metrics;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Update active time tracking
|
|
597
|
+
*/
|
|
598
|
+
function updateActiveTime() {
|
|
599
|
+
if (!currentSession || !config?.trackActiveTime)
|
|
600
|
+
return;
|
|
601
|
+
const now = Date.now();
|
|
602
|
+
const idleTimeout = config.idleTimeout || 5 * 60 * 1000;
|
|
603
|
+
const timeSinceLastActivity = now - currentSession.lastActivityTime;
|
|
604
|
+
if (timeSinceLastActivity < idleTimeout) {
|
|
605
|
+
currentSession.activeTimeMs += timeSinceLastActivity;
|
|
606
|
+
}
|
|
607
|
+
currentSession.lastActivityTime = now;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Track an AI request/response with full metrics
|
|
611
|
+
*/
|
|
612
|
+
function trackAIRequest(params) {
|
|
613
|
+
updateActiveTime();
|
|
614
|
+
const cost = calculateCost(params.model, params.inputTokens, params.outputTokens, {
|
|
615
|
+
cacheReadTokens: params.cacheReadTokens,
|
|
616
|
+
cacheWriteTokens: params.cacheWriteTokens,
|
|
617
|
+
thinkingTokens: params.thinkingTokens,
|
|
618
|
+
});
|
|
619
|
+
// Track per-file costs
|
|
620
|
+
if (currentSession && params.filesContext) {
|
|
621
|
+
const costPerFile = cost / params.filesContext.length;
|
|
622
|
+
for (const file of params.filesContext) {
|
|
623
|
+
const current = currentSession.fileCosts.get(file) || 0;
|
|
624
|
+
currentSession.fileCosts.set(file, current + costPerFile);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
// Track per-language costs
|
|
628
|
+
if (currentSession && params.language) {
|
|
629
|
+
const current = currentSession.languageCosts.get(params.language) || 0;
|
|
630
|
+
currentSession.languageCosts.set(params.language, current + cost);
|
|
631
|
+
}
|
|
632
|
+
// Track response times
|
|
633
|
+
if (currentSession) {
|
|
634
|
+
if (params.totalResponseTime) {
|
|
635
|
+
currentSession.responseTimes.push(params.totalResponseTime);
|
|
636
|
+
}
|
|
637
|
+
if (params.timeToFirstToken) {
|
|
638
|
+
currentSession.timeToFirstTokens.push(params.timeToFirstToken);
|
|
639
|
+
}
|
|
640
|
+
if (params.contextUsage !== undefined) {
|
|
641
|
+
currentSession.contextUsages.push(params.contextUsage);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
track({
|
|
645
|
+
type: 'ai_request',
|
|
646
|
+
timestamp: new Date().toISOString(),
|
|
647
|
+
sessionId: currentSession?.id || 'unknown',
|
|
648
|
+
projectId: config?.projectId || 'unknown',
|
|
649
|
+
model: params.model,
|
|
650
|
+
inputTokens: params.inputTokens,
|
|
651
|
+
outputTokens: params.outputTokens,
|
|
652
|
+
cacheReadTokens: params.cacheReadTokens,
|
|
653
|
+
cacheWriteTokens: params.cacheWriteTokens,
|
|
654
|
+
thinkingTokens: params.thinkingTokens,
|
|
655
|
+
promptLength: params.promptLength,
|
|
656
|
+
responseLength: params.responseLength,
|
|
657
|
+
contextUsage: params.contextUsage,
|
|
658
|
+
timeToFirstToken: params.timeToFirstToken,
|
|
659
|
+
totalResponseTime: params.totalResponseTime,
|
|
660
|
+
estimatedCost: cost,
|
|
661
|
+
promptCached: (params.cacheReadTokens || 0) > 0,
|
|
662
|
+
filesModified: params.filesContext,
|
|
663
|
+
language: params.language,
|
|
664
|
+
taskContext: params.taskContext,
|
|
665
|
+
metadata: params.metadata,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Track a conversation turn (user message + AI response cycle)
|
|
670
|
+
*/
|
|
671
|
+
function trackConversationTurn(params) {
|
|
672
|
+
updateActiveTime();
|
|
673
|
+
if (currentSession) {
|
|
674
|
+
currentSession.turnNumber++;
|
|
675
|
+
}
|
|
676
|
+
track({
|
|
677
|
+
type: 'conversation_turn',
|
|
678
|
+
timestamp: new Date().toISOString(),
|
|
679
|
+
sessionId: currentSession?.id || 'unknown',
|
|
680
|
+
projectId: config?.projectId || 'unknown',
|
|
681
|
+
turnNumber: currentSession?.turnNumber || 1,
|
|
682
|
+
userIntervention: params.userIntervention,
|
|
683
|
+
taskContext: params.taskContext,
|
|
684
|
+
metadata: params.metadata,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Track a tool call with enhanced metrics
|
|
689
|
+
*/
|
|
690
|
+
function trackToolCall(params) {
|
|
691
|
+
updateActiveTime();
|
|
692
|
+
// Track tool success/failure by name
|
|
693
|
+
if (currentSession) {
|
|
694
|
+
const stats = currentSession.toolCalls.get(params.toolName) || { success: 0, fail: 0 };
|
|
695
|
+
if (params.success) {
|
|
696
|
+
stats.success++;
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
stats.fail++;
|
|
700
|
+
}
|
|
701
|
+
currentSession.toolCalls.set(params.toolName, stats);
|
|
702
|
+
// Track file changes
|
|
703
|
+
if (params.filesModified) {
|
|
704
|
+
for (const file of params.filesModified) {
|
|
705
|
+
const changes = (params.linesAdded || 0) + (params.linesRemoved || 0);
|
|
706
|
+
const current = currentSession.fileChanges.get(file) || 0;
|
|
707
|
+
currentSession.fileChanges.set(file, current + changes);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
track({
|
|
712
|
+
type: 'tool_call',
|
|
713
|
+
timestamp: new Date().toISOString(),
|
|
714
|
+
sessionId: currentSession?.id || 'unknown',
|
|
715
|
+
projectId: config?.projectId || 'unknown',
|
|
716
|
+
toolName: params.toolName,
|
|
717
|
+
toolSuccess: params.success,
|
|
718
|
+
toolDuration: params.duration,
|
|
719
|
+
toolInputSize: params.inputSize,
|
|
720
|
+
toolOutputSize: params.outputSize,
|
|
721
|
+
linesAdded: params.linesAdded,
|
|
722
|
+
linesRemoved: params.linesRemoved,
|
|
723
|
+
filesModified: config?.includeFilePaths ? params.filesModified : undefined,
|
|
724
|
+
language: params.language,
|
|
725
|
+
fileOperation: params.fileOperation,
|
|
726
|
+
retryAttempt: params.retryAttempt,
|
|
727
|
+
metadata: params.metadata,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Track file operation (read/write/edit)
|
|
732
|
+
*/
|
|
733
|
+
function trackFileOperation(params) {
|
|
734
|
+
updateActiveTime();
|
|
735
|
+
if (currentSession) {
|
|
736
|
+
const changes = (params.linesAdded || 0) + (params.linesRemoved || 0);
|
|
737
|
+
const current = currentSession.fileChanges.get(params.file) || 0;
|
|
738
|
+
currentSession.fileChanges.set(params.file, current + changes);
|
|
739
|
+
}
|
|
740
|
+
track({
|
|
741
|
+
type: 'file_operation',
|
|
742
|
+
timestamp: new Date().toISOString(),
|
|
743
|
+
sessionId: currentSession?.id || 'unknown',
|
|
744
|
+
projectId: config?.projectId || 'unknown',
|
|
745
|
+
fileOperation: params.operation,
|
|
746
|
+
filesModified: [params.file],
|
|
747
|
+
linesAdded: params.linesAdded,
|
|
748
|
+
linesRemoved: params.linesRemoved,
|
|
749
|
+
language: params.language,
|
|
750
|
+
toolDuration: params.duration,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Track code acceptance/rejection with context
|
|
755
|
+
*/
|
|
756
|
+
function trackCodeDecision(params) {
|
|
757
|
+
updateActiveTime();
|
|
758
|
+
track({
|
|
759
|
+
type: params.accepted ? 'code_accept' : 'code_reject',
|
|
760
|
+
timestamp: new Date().toISOString(),
|
|
761
|
+
sessionId: currentSession?.id || 'unknown',
|
|
762
|
+
projectId: config?.projectId || 'unknown',
|
|
763
|
+
linesAdded: params.linesAdded,
|
|
764
|
+
linesRemoved: params.linesRemoved,
|
|
765
|
+
filesModified: config?.includeFilePaths ? params.filesModified : undefined,
|
|
766
|
+
language: params.language,
|
|
767
|
+
userIntervention: params.userIntervention,
|
|
768
|
+
taskContext: params.taskContext,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Track an error event
|
|
773
|
+
*/
|
|
774
|
+
function trackError(params) {
|
|
775
|
+
track({
|
|
776
|
+
type: 'error',
|
|
777
|
+
timestamp: new Date().toISOString(),
|
|
778
|
+
sessionId: currentSession?.id || 'unknown',
|
|
779
|
+
projectId: config?.projectId || 'unknown',
|
|
780
|
+
errorMessage: params.message,
|
|
781
|
+
errorRecovered: params.recovered,
|
|
782
|
+
retryAttempt: params.retryAttempt,
|
|
783
|
+
toolName: params.toolName,
|
|
784
|
+
metadata: params.metadata,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Track a rule violation (coding standard violation, pattern anti-pattern, etc.)
|
|
789
|
+
*
|
|
790
|
+
* Use this to track when AI-generated code violates established
|
|
791
|
+
* project rules like those in CLAUDE.md, eslint configs, or architectural patterns.
|
|
792
|
+
*
|
|
793
|
+
* @example
|
|
794
|
+
* trackRuleDeviation({
|
|
795
|
+
* ruleName: 'use-bulkwrite',
|
|
796
|
+
* ruleSource: 'CLAUDE.md',
|
|
797
|
+
* category: 'db_pattern',
|
|
798
|
+
* severity: 'warning',
|
|
799
|
+
* description: 'MongoDB writes should use bulkWrite instead of individual operations',
|
|
800
|
+
* violatingCode: 'await collection.updateOne(...)',
|
|
801
|
+
* suggestedFix: 'Use collection.bulkWrite([{ updateOne: {...} }])',
|
|
802
|
+
* file: 'src/api/ingest.ts',
|
|
803
|
+
* line: 238,
|
|
804
|
+
* corrected: true,
|
|
805
|
+
* confidence: 0.95,
|
|
806
|
+
* });
|
|
807
|
+
*/
|
|
808
|
+
function trackRuleDeviation(params) {
|
|
809
|
+
const category = params.category || 'custom';
|
|
810
|
+
const severity = params.severity || 'warning';
|
|
811
|
+
const source = params.ruleSource || 'unknown';
|
|
812
|
+
// Update session tracking
|
|
813
|
+
if (currentSession) {
|
|
814
|
+
// Track by rule name
|
|
815
|
+
const existing = currentSession.ruleViolations.get(params.ruleName);
|
|
816
|
+
if (existing) {
|
|
817
|
+
existing.count++;
|
|
818
|
+
}
|
|
819
|
+
else {
|
|
820
|
+
currentSession.ruleViolations.set(params.ruleName, { count: 1, severity });
|
|
821
|
+
}
|
|
822
|
+
// Track by category
|
|
823
|
+
const categoryCount = currentSession.violationsByCategory.get(category) || 0;
|
|
824
|
+
currentSession.violationsByCategory.set(category, categoryCount + 1);
|
|
825
|
+
// Track by severity
|
|
826
|
+
const severityCount = currentSession.violationsBySeverity.get(severity) || 0;
|
|
827
|
+
currentSession.violationsBySeverity.set(severity, severityCount + 1);
|
|
828
|
+
// Track by source
|
|
829
|
+
const sourceCount = currentSession.violationsBySource.get(source) || 0;
|
|
830
|
+
currentSession.violationsBySource.set(source, sourceCount + 1);
|
|
831
|
+
// Track corrections
|
|
832
|
+
if (params.corrected) {
|
|
833
|
+
currentSession.correctionsCount++;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
track({
|
|
837
|
+
type: 'rule_deviation',
|
|
838
|
+
timestamp: new Date().toISOString(),
|
|
839
|
+
sessionId: currentSession?.id || 'unknown',
|
|
840
|
+
projectId: config?.projectId || 'unknown',
|
|
841
|
+
ruleName: params.ruleName,
|
|
842
|
+
ruleSource: source,
|
|
843
|
+
ruleCategory: category,
|
|
844
|
+
ruleSeverity: severity,
|
|
845
|
+
ruleDescription: params.description,
|
|
846
|
+
violatingCode: params.violatingCode,
|
|
847
|
+
suggestedFix: params.suggestedFix,
|
|
848
|
+
violationFile: params.file,
|
|
849
|
+
violationLine: params.line,
|
|
850
|
+
corrected: params.corrected,
|
|
851
|
+
violationConfidence: params.confidence,
|
|
852
|
+
metadata: params.metadata,
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Track a generic event
|
|
857
|
+
*/
|
|
858
|
+
function track(event) {
|
|
859
|
+
// Add version info from config (customer-defined via env vars)
|
|
860
|
+
if (config?.version) {
|
|
861
|
+
event.version = config.version;
|
|
862
|
+
}
|
|
863
|
+
if (config?.commit) {
|
|
864
|
+
event.configCommit = config.commit;
|
|
865
|
+
}
|
|
866
|
+
// Automatically add git context to all events
|
|
867
|
+
if (config?.trackGitContext !== false && gitContext) {
|
|
868
|
+
event.gitUsername = gitContext.username;
|
|
869
|
+
event.gitEmail = gitContext.email;
|
|
870
|
+
event.gitRepo = gitContext.repoName;
|
|
871
|
+
event.gitBranch = gitContext.branch;
|
|
872
|
+
event.gitCommit = gitContext.commit;
|
|
873
|
+
event.gitDirty = gitContext.isDirty;
|
|
874
|
+
}
|
|
875
|
+
if (currentSession) {
|
|
876
|
+
currentSession.events.push(event);
|
|
877
|
+
}
|
|
878
|
+
eventBuffer.push(event);
|
|
879
|
+
if (config?.debug) {
|
|
880
|
+
console.log('[rulecatch-ai] Event:', event.type, event);
|
|
881
|
+
}
|
|
882
|
+
// Check if we should flush
|
|
883
|
+
if (eventBuffer.length >= (config?.batchSize || 10)) {
|
|
884
|
+
flush();
|
|
885
|
+
}
|
|
886
|
+
else if (!flushTimeout && config?.flushInterval) {
|
|
887
|
+
flushTimeout = setTimeout(flush, config.flushInterval);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Flush events to Rulecatch API
|
|
892
|
+
*/
|
|
893
|
+
async function flush() {
|
|
894
|
+
if (flushTimeout) {
|
|
895
|
+
clearTimeout(flushTimeout);
|
|
896
|
+
flushTimeout = null;
|
|
897
|
+
}
|
|
898
|
+
if (eventBuffer.length === 0)
|
|
899
|
+
return;
|
|
900
|
+
const events = [...eventBuffer];
|
|
901
|
+
eventBuffer.length = 0;
|
|
902
|
+
if (!config?.licenseKey || !config?.endpoint) {
|
|
903
|
+
if (config?.debug) {
|
|
904
|
+
console.log('[rulecatch-ai] No license key or endpoint configured, skipping flush');
|
|
905
|
+
}
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
try {
|
|
909
|
+
const response = await fetch(config.endpoint, {
|
|
910
|
+
method: 'POST',
|
|
911
|
+
headers: {
|
|
912
|
+
'Content-Type': 'application/json',
|
|
913
|
+
Authorization: `Bearer ${config.licenseKey}`,
|
|
914
|
+
},
|
|
915
|
+
body: JSON.stringify({
|
|
916
|
+
projectId: config.projectId,
|
|
917
|
+
events,
|
|
918
|
+
gitContext: config.trackGitContext !== false ? {
|
|
919
|
+
username: gitContext.username,
|
|
920
|
+
email: gitContext.email,
|
|
921
|
+
repo: gitContext.repoName,
|
|
922
|
+
branch: gitContext.branch,
|
|
923
|
+
commit: gitContext.commit,
|
|
924
|
+
isDirty: gitContext.isDirty,
|
|
925
|
+
} : undefined,
|
|
926
|
+
}),
|
|
927
|
+
});
|
|
928
|
+
if (!response.ok) {
|
|
929
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
930
|
+
}
|
|
931
|
+
if (config.debug) {
|
|
932
|
+
console.log(`[rulecatch-ai] Flushed ${events.length} events`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
catch (error) {
|
|
936
|
+
if (config?.debug) {
|
|
937
|
+
console.error('[rulecatch-ai] Failed to flush events:', error);
|
|
938
|
+
}
|
|
939
|
+
// Re-add events to buffer for retry
|
|
940
|
+
eventBuffer.unshift(...events);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Calculate comprehensive session metrics from events
|
|
945
|
+
*/
|
|
946
|
+
function calculateSessionMetrics(events) {
|
|
947
|
+
const sessionStart = events.find((e) => e.type === 'session_start');
|
|
948
|
+
const sessionEnd = events.find((e) => e.type === 'session_end');
|
|
949
|
+
const startTime = sessionStart?.timestamp || new Date().toISOString();
|
|
950
|
+
const endTime = sessionEnd?.timestamp;
|
|
951
|
+
const duration = endTime
|
|
952
|
+
? (new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000
|
|
953
|
+
: 0;
|
|
954
|
+
const activeDuration = currentSession
|
|
955
|
+
? currentSession.activeTimeMs / 1000
|
|
956
|
+
: duration;
|
|
957
|
+
const aiRequests = events.filter((e) => e.type === 'ai_request');
|
|
958
|
+
const toolCalls = events.filter((e) => e.type === 'tool_call');
|
|
959
|
+
const codeAccepts = events.filter((e) => e.type === 'code_accept');
|
|
960
|
+
const codeRejects = events.filter((e) => e.type === 'code_reject');
|
|
961
|
+
const turns = events.filter((e) => e.type === 'conversation_turn');
|
|
962
|
+
const errors = events.filter((e) => e.type === 'error');
|
|
963
|
+
const interventions = events.filter((e) => e.userIntervention);
|
|
964
|
+
// Token metrics
|
|
965
|
+
const totalInputTokens = aiRequests.reduce((sum, e) => sum + (e.inputTokens || 0), 0);
|
|
966
|
+
const totalOutputTokens = aiRequests.reduce((sum, e) => sum + (e.outputTokens || 0), 0);
|
|
967
|
+
const totalThinkingTokens = aiRequests.reduce((sum, e) => sum + (e.thinkingTokens || 0), 0);
|
|
968
|
+
const totalCacheReadTokens = aiRequests.reduce((sum, e) => sum + (e.cacheReadTokens || 0), 0);
|
|
969
|
+
const totalCost = aiRequests.reduce((sum, e) => sum + (e.estimatedCost || 0), 0);
|
|
970
|
+
// Cache hit rate
|
|
971
|
+
const cachedRequests = aiRequests.filter((e) => e.promptCached);
|
|
972
|
+
const cacheHitRate = aiRequests.length > 0 ? cachedRequests.length / aiRequests.length : 0;
|
|
973
|
+
// Context usage
|
|
974
|
+
const contextUsages = aiRequests.map((e) => e.contextUsage).filter((c) => c !== undefined);
|
|
975
|
+
const avgContextUsage = contextUsages.length > 0
|
|
976
|
+
? contextUsages.reduce((a, b) => a + b, 0) / contextUsages.length
|
|
977
|
+
: 0;
|
|
978
|
+
// Response times
|
|
979
|
+
const responseTimes = currentSession?.responseTimes || [];
|
|
980
|
+
const avgResponseTime = responseTimes.length > 0
|
|
981
|
+
? responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length
|
|
982
|
+
: 0;
|
|
983
|
+
const timeToFirstTokens = currentSession?.timeToFirstTokens || [];
|
|
984
|
+
const avgTimeToFirstToken = timeToFirstTokens.length > 0
|
|
985
|
+
? timeToFirstTokens.reduce((a, b) => a + b, 0) / timeToFirstTokens.length
|
|
986
|
+
: 0;
|
|
987
|
+
// Tool metrics
|
|
988
|
+
const successfulTools = toolCalls.filter((e) => e.toolSuccess);
|
|
989
|
+
const toolSuccessRate = toolCalls.length > 0 ? successfulTools.length / toolCalls.length : 1;
|
|
990
|
+
const toolCallsByName = {};
|
|
991
|
+
const toolFailuresByName = {};
|
|
992
|
+
if (currentSession) {
|
|
993
|
+
for (const [name, stats] of currentSession.toolCalls) {
|
|
994
|
+
toolCallsByName[name] = stats.success + stats.fail;
|
|
995
|
+
if (stats.fail > 0) {
|
|
996
|
+
toolFailuresByName[name] = stats.fail;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
// Code metrics
|
|
1001
|
+
const allLinesAdded = events.reduce((sum, e) => sum + (e.linesAdded || 0), 0);
|
|
1002
|
+
const allLinesRemoved = events.reduce((sum, e) => sum + (e.linesRemoved || 0), 0);
|
|
1003
|
+
const allFiles = new Set();
|
|
1004
|
+
events.forEach((e) => e.filesModified?.forEach((f) => allFiles.add(f)));
|
|
1005
|
+
// Top modified files
|
|
1006
|
+
const topModifiedFiles = [];
|
|
1007
|
+
if (currentSession) {
|
|
1008
|
+
const sorted = [...currentSession.fileChanges.entries()]
|
|
1009
|
+
.sort((a, b) => b[1] - a[1])
|
|
1010
|
+
.slice(0, 10);
|
|
1011
|
+
for (const [file, changes] of sorted) {
|
|
1012
|
+
topModifiedFiles.push({ file, changes });
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
// Cost breakdowns
|
|
1016
|
+
const costByFile = {};
|
|
1017
|
+
const costByLanguage = {};
|
|
1018
|
+
if (currentSession) {
|
|
1019
|
+
for (const [file, cost] of currentSession.fileCosts) {
|
|
1020
|
+
costByFile[file] = Math.round(cost * 10000) / 10000;
|
|
1021
|
+
}
|
|
1022
|
+
for (const [lang, cost] of currentSession.languageCosts) {
|
|
1023
|
+
costByLanguage[lang] = Math.round(cost * 10000) / 10000;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
// Acceptance and intervention rates
|
|
1027
|
+
const codeDecisions = codeAccepts.length + codeRejects.length;
|
|
1028
|
+
const codeAcceptanceRate = codeDecisions > 0 ? codeAccepts.length / codeDecisions : 1;
|
|
1029
|
+
const userInterventionRate = turns.length > 0 ? interventions.length / turns.length : 0;
|
|
1030
|
+
// Model usage
|
|
1031
|
+
const modelCounts = {};
|
|
1032
|
+
aiRequests.forEach((e) => {
|
|
1033
|
+
const model = e.model || 'unknown';
|
|
1034
|
+
modelCounts[model] = (modelCounts[model] || 0) + 1;
|
|
1035
|
+
});
|
|
1036
|
+
const primaryModel = Object.entries(modelCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'unknown';
|
|
1037
|
+
const languages = [...new Set(events.map((e) => e.language).filter(Boolean))];
|
|
1038
|
+
// Productivity metrics
|
|
1039
|
+
const activeMinutes = activeDuration / 60;
|
|
1040
|
+
const tokensPerMinute = activeMinutes > 0 ? (totalInputTokens + totalOutputTokens) / activeMinutes : 0;
|
|
1041
|
+
const activeHours = activeDuration / 3600;
|
|
1042
|
+
const linesPerHour = activeHours > 0 ? allLinesAdded / activeHours : 0;
|
|
1043
|
+
const costPerLine = allLinesAdded > 0 ? totalCost / allLinesAdded : 0;
|
|
1044
|
+
// Error metrics
|
|
1045
|
+
const recoveredErrors = errors.filter((e) => e.errorRecovered);
|
|
1046
|
+
const errorRecoveryRate = errors.length > 0 ? recoveredErrors.length / errors.length : 1;
|
|
1047
|
+
// Rule violation metrics
|
|
1048
|
+
const ruleViolationEvents = events.filter((e) => e.type === 'rule_deviation');
|
|
1049
|
+
const totalRuleViolations = ruleViolationEvents.length;
|
|
1050
|
+
const violationsByCategory = {
|
|
1051
|
+
coding_standard: 0,
|
|
1052
|
+
db_pattern: 0,
|
|
1053
|
+
security: 0,
|
|
1054
|
+
performance: 0,
|
|
1055
|
+
architecture: 0,
|
|
1056
|
+
documentation: 0,
|
|
1057
|
+
testing: 0,
|
|
1058
|
+
custom: 0,
|
|
1059
|
+
};
|
|
1060
|
+
const violationsBySeverity = {
|
|
1061
|
+
info: 0,
|
|
1062
|
+
warning: 0,
|
|
1063
|
+
error: 0,
|
|
1064
|
+
};
|
|
1065
|
+
const violationsBySource = {};
|
|
1066
|
+
const ruleViolationCounts = {};
|
|
1067
|
+
const fileViolationCounts = {};
|
|
1068
|
+
let correctedCount = 0;
|
|
1069
|
+
// Process violation events
|
|
1070
|
+
ruleViolationEvents.forEach((d) => {
|
|
1071
|
+
if (d.ruleCategory) {
|
|
1072
|
+
violationsByCategory[d.ruleCategory] = (violationsByCategory[d.ruleCategory] || 0) + 1;
|
|
1073
|
+
}
|
|
1074
|
+
if (d.ruleSeverity) {
|
|
1075
|
+
violationsBySeverity[d.ruleSeverity] = (violationsBySeverity[d.ruleSeverity] || 0) + 1;
|
|
1076
|
+
}
|
|
1077
|
+
if (d.ruleSource) {
|
|
1078
|
+
violationsBySource[d.ruleSource] = (violationsBySource[d.ruleSource] || 0) + 1;
|
|
1079
|
+
}
|
|
1080
|
+
if (d.ruleName) {
|
|
1081
|
+
if (!ruleViolationCounts[d.ruleName]) {
|
|
1082
|
+
ruleViolationCounts[d.ruleName] = { count: 0, severity: d.ruleSeverity || 'warning' };
|
|
1083
|
+
}
|
|
1084
|
+
ruleViolationCounts[d.ruleName].count++;
|
|
1085
|
+
}
|
|
1086
|
+
if (d.violationFile) {
|
|
1087
|
+
fileViolationCounts[d.violationFile] = (fileViolationCounts[d.violationFile] || 0) + 1;
|
|
1088
|
+
}
|
|
1089
|
+
if (d.corrected) {
|
|
1090
|
+
correctedCount++;
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
// Use session data if available (more accurate)
|
|
1094
|
+
if (currentSession) {
|
|
1095
|
+
for (const [category, count] of currentSession.violationsByCategory) {
|
|
1096
|
+
violationsByCategory[category] = count;
|
|
1097
|
+
}
|
|
1098
|
+
for (const [severity, count] of currentSession.violationsBySeverity) {
|
|
1099
|
+
violationsBySeverity[severity] = count;
|
|
1100
|
+
}
|
|
1101
|
+
for (const [source, count] of currentSession.violationsBySource) {
|
|
1102
|
+
violationsBySource[source] = count;
|
|
1103
|
+
}
|
|
1104
|
+
correctedCount = currentSession.correctionsCount;
|
|
1105
|
+
}
|
|
1106
|
+
// Top rule violations
|
|
1107
|
+
const topRuleViolations = Object.entries(ruleViolationCounts)
|
|
1108
|
+
.map(([rule, { count, severity }]) => ({ rule, count, severity }))
|
|
1109
|
+
.sort((a, b) => b.count - a.count)
|
|
1110
|
+
.slice(0, 10);
|
|
1111
|
+
// Files with most violations
|
|
1112
|
+
const filesWithMostViolations = Object.entries(fileViolationCounts)
|
|
1113
|
+
.map(([file, count]) => ({ file, count }))
|
|
1114
|
+
.sort((a, b) => b.count - a.count)
|
|
1115
|
+
.slice(0, 10);
|
|
1116
|
+
const violationCorrectionRate = totalRuleViolations > 0
|
|
1117
|
+
? correctedCount / totalRuleViolations
|
|
1118
|
+
: 1;
|
|
1119
|
+
return {
|
|
1120
|
+
sessionId: currentSession?.id || 'unknown',
|
|
1121
|
+
startTime,
|
|
1122
|
+
endTime,
|
|
1123
|
+
duration,
|
|
1124
|
+
activeDuration,
|
|
1125
|
+
totalInputTokens,
|
|
1126
|
+
totalOutputTokens,
|
|
1127
|
+
totalThinkingTokens,
|
|
1128
|
+
totalCacheReadTokens,
|
|
1129
|
+
cacheHitRate,
|
|
1130
|
+
avgContextUsage,
|
|
1131
|
+
totalCost,
|
|
1132
|
+
costByFile,
|
|
1133
|
+
costByLanguage,
|
|
1134
|
+
requestCount: aiRequests.length,
|
|
1135
|
+
conversationTurns: turns.length,
|
|
1136
|
+
avgResponseTime,
|
|
1137
|
+
avgTimeToFirstToken,
|
|
1138
|
+
toolCallCount: toolCalls.length,
|
|
1139
|
+
toolSuccessRate,
|
|
1140
|
+
toolCallsByName,
|
|
1141
|
+
toolFailuresByName,
|
|
1142
|
+
totalLinesAdded: allLinesAdded,
|
|
1143
|
+
totalLinesRemoved: allLinesRemoved,
|
|
1144
|
+
netLinesChanged: allLinesAdded - allLinesRemoved,
|
|
1145
|
+
uniqueFilesModified: allFiles.size,
|
|
1146
|
+
topModifiedFiles,
|
|
1147
|
+
codeAcceptanceRate,
|
|
1148
|
+
userInterventionRate,
|
|
1149
|
+
primaryModel,
|
|
1150
|
+
modelUsage: modelCounts,
|
|
1151
|
+
languages,
|
|
1152
|
+
tokensPerMinute: Math.round(tokensPerMinute),
|
|
1153
|
+
linesPerHour: Math.round(linesPerHour * 10) / 10,
|
|
1154
|
+
costPerLine: Math.round(costPerLine * 10000) / 10000,
|
|
1155
|
+
errorCount: errors.length,
|
|
1156
|
+
errorRecoveryRate,
|
|
1157
|
+
// Rule violation metrics
|
|
1158
|
+
totalRuleViolations,
|
|
1159
|
+
violationsByCategory,
|
|
1160
|
+
violationsBySeverity,
|
|
1161
|
+
violationsBySource,
|
|
1162
|
+
topRuleViolations,
|
|
1163
|
+
violationCorrectionRate,
|
|
1164
|
+
filesWithMostViolations,
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Generate a unique session ID
|
|
1169
|
+
*/
|
|
1170
|
+
function generateSessionId() {
|
|
1171
|
+
const timestamp = Date.now().toString(36);
|
|
1172
|
+
const random = Math.random().toString(36).substring(2, 10);
|
|
1173
|
+
return `ai_${timestamp}_${random}`;
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Get current session metrics
|
|
1177
|
+
*/
|
|
1178
|
+
function getSessionMetrics() {
|
|
1179
|
+
if (!currentSession)
|
|
1180
|
+
return null;
|
|
1181
|
+
updateActiveTime();
|
|
1182
|
+
return calculateSessionMetrics(currentSession.events);
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Check if a session is active
|
|
1186
|
+
*/
|
|
1187
|
+
function isSessionActive() {
|
|
1188
|
+
return currentSession !== null;
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* Get current session ID
|
|
1192
|
+
*/
|
|
1193
|
+
function getSessionId() {
|
|
1194
|
+
return currentSession?.id || null;
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Pause session (marks idle period)
|
|
1198
|
+
*/
|
|
1199
|
+
function pauseSession() {
|
|
1200
|
+
if (!currentSession)
|
|
1201
|
+
return;
|
|
1202
|
+
updateActiveTime();
|
|
1203
|
+
track({
|
|
1204
|
+
type: 'session_pause',
|
|
1205
|
+
timestamp: new Date().toISOString(),
|
|
1206
|
+
sessionId: currentSession.id,
|
|
1207
|
+
projectId: config?.projectId || 'unknown',
|
|
1208
|
+
activeTime: currentSession.activeTimeMs,
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Resume session
|
|
1213
|
+
*/
|
|
1214
|
+
function resumeSession() {
|
|
1215
|
+
if (!currentSession)
|
|
1216
|
+
return;
|
|
1217
|
+
currentSession.lastActivityTime = Date.now();
|
|
1218
|
+
track({
|
|
1219
|
+
type: 'session_resume',
|
|
1220
|
+
timestamp: new Date().toISOString(),
|
|
1221
|
+
sessionId: currentSession.id,
|
|
1222
|
+
projectId: config?.projectId || 'unknown',
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Get current git context
|
|
1227
|
+
*/
|
|
1228
|
+
function getGitContext() {
|
|
1229
|
+
return { ...gitContext };
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Manually refresh git context
|
|
1233
|
+
*/
|
|
1234
|
+
function refreshGitContext() {
|
|
1235
|
+
gitContext = collectGitContext(config?.cwd);
|
|
1236
|
+
return { ...gitContext };
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
export { MODEL_PRICING, calculateCost, collectGitContext, decryptEventPII, decryptPII, deriveKey, encryptEventPII, encryptPII, endSession, estimateTokens, flush, formatCost, formatDuration, generateSalt, getGitContext, getGitRoot, getGitSummary, getSessionId, getSessionMetrics, hashForIndex, init, isGitRepo, isSessionActive, pauseSession, refreshGitContext, resumeSession, startSession, track, trackAIRequest, trackCodeDecision, trackConversationTurn, trackError, trackFileOperation, trackRuleDeviation, trackToolCall, verifyPrivacyKey, watchGitChanges };
|
|
1240
|
+
//# sourceMappingURL=index.js.map
|