ai-lens 0.7.3 → 0.7.4
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/.commithash +1 -1
- package/cli/hooks.js +10 -22
- package/cli/init.js +87 -31
- package/client/capture.js +82 -33
- package/client/config.js +18 -7
- package/client/redact.js +10 -1
- package/client/sender.js +82 -19
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
8b0424f
|
package/cli/hooks.js
CHANGED
|
@@ -44,11 +44,11 @@ export function readLensConfig() {
|
|
|
44
44
|
|
|
45
45
|
export function saveLensConfig(config) {
|
|
46
46
|
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
47
|
-
|
|
47
|
+
const tmpPath = CONFIG_PATH + '.tmp.' + process.pid;
|
|
48
|
+
writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n');
|
|
49
|
+
renameSync(tmpPath, CONFIG_PATH);
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
const DEFAULT_SERVER_URL = 'http://localhost:3000';
|
|
51
|
-
|
|
52
52
|
/**
|
|
53
53
|
* Escape a string for safe embedding in a single-quoted shell context.
|
|
54
54
|
* Standard POSIX approach: replace each ' with '\'' (end quote, escaped quote, start quote).
|
|
@@ -59,19 +59,8 @@ export function shellEscape(str) {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
function captureCommand() {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
if (config.serverUrl && config.serverUrl !== DEFAULT_SERVER_URL) {
|
|
65
|
-
envs.push(`AI_LENS_SERVER_URL=${shellEscape(config.serverUrl)}`);
|
|
66
|
-
}
|
|
67
|
-
if (config.projects) {
|
|
68
|
-
envs.push(`AI_LENS_PROJECTS=${shellEscape(config.projects)}`);
|
|
69
|
-
}
|
|
70
|
-
if (config.authToken) {
|
|
71
|
-
envs.push(`AI_LENS_AUTH_TOKEN=${shellEscape(config.authToken)}`);
|
|
72
|
-
}
|
|
73
|
-
const base = `node ${CAPTURE_PATH}`;
|
|
74
|
-
return envs.length > 0 ? `${envs.join(' ')} ${base}` : base;
|
|
62
|
+
const escaped = shellEscape(CAPTURE_PATH);
|
|
63
|
+
return `${shellEscape(process.execPath)} ${escaped} || node ${escaped}`;
|
|
75
64
|
}
|
|
76
65
|
|
|
77
66
|
// ---------------------------------------------------------------------------
|
|
@@ -169,13 +158,10 @@ export const TOOL_CONFIGS = [
|
|
|
169
158
|
export function isAiLensHook(entry) {
|
|
170
159
|
// Flat format (Cursor): { command: "..." }
|
|
171
160
|
const cmd = entry?.command || '';
|
|
172
|
-
if (cmd.includes(
|
|
161
|
+
if (cmd.includes(CAPTURE_PATH)) return true;
|
|
173
162
|
// Nested format (Claude Code): { matcher, hooks: [{ command: "..." }] }
|
|
174
163
|
if (Array.isArray(entry?.hooks)) {
|
|
175
|
-
return entry.hooks.some(h =>
|
|
176
|
-
const c = h?.command || '';
|
|
177
|
-
return c.includes('ai-lens') && c.includes('capture.js');
|
|
178
|
-
});
|
|
164
|
+
return entry.hooks.some(h => (h?.command || '').includes(CAPTURE_PATH));
|
|
179
165
|
}
|
|
180
166
|
return false;
|
|
181
167
|
}
|
|
@@ -345,7 +331,9 @@ export function buildStrippedConfig(tool, existingConfig) {
|
|
|
345
331
|
|
|
346
332
|
export function writeHooksConfig(tool, config) {
|
|
347
333
|
mkdirSync(dirname(tool.configPath), { recursive: true });
|
|
348
|
-
|
|
334
|
+
const tmpPath = tool.configPath + '.tmp.' + process.pid;
|
|
335
|
+
writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n');
|
|
336
|
+
renameSync(tmpPath, tool.configPath);
|
|
349
337
|
}
|
|
350
338
|
|
|
351
339
|
// ---------------------------------------------------------------------------
|
package/cli/init.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
2
|
import { execSync } from 'node:child_process';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
4
|
-
import { join } from 'node:path';
|
|
3
|
+
import { existsSync, copyFileSync } from 'node:fs';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { request as httpRequest } from 'node:http';
|
|
7
7
|
import { request as httpsRequest } from 'node:https';
|
|
@@ -229,10 +229,11 @@ async function deviceCodeAuth(serverUrl) {
|
|
|
229
229
|
|
|
230
230
|
/**
|
|
231
231
|
* Validate an existing auth token against the server.
|
|
232
|
-
* Returns
|
|
232
|
+
* Returns 'valid', 'invalid' (401 — revoked/wrong), or 'unreachable' (network error).
|
|
233
233
|
*/
|
|
234
234
|
async function validateExistingToken(serverUrl, token) {
|
|
235
|
-
if (!token
|
|
235
|
+
if (!token) return 'invalid';
|
|
236
|
+
if (!token.startsWith('ailens_dev_')) return 'unknown'; // legacy format — keep it
|
|
236
237
|
try {
|
|
237
238
|
const parsed = new URL(`${serverUrl}/api/auth/verify`);
|
|
238
239
|
const isHttps = parsed.protocol === 'https:';
|
|
@@ -253,9 +254,11 @@ async function validateExistingToken(serverUrl, token) {
|
|
|
253
254
|
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
|
254
255
|
req.end();
|
|
255
256
|
});
|
|
256
|
-
|
|
257
|
+
if (status === 200) return 'valid';
|
|
258
|
+
if (status === 401) return 'invalid';
|
|
259
|
+
return 'unreachable';
|
|
257
260
|
} catch {
|
|
258
|
-
return
|
|
261
|
+
return 'unreachable';
|
|
259
262
|
}
|
|
260
263
|
}
|
|
261
264
|
|
|
@@ -336,6 +339,8 @@ export default async function init() {
|
|
|
336
339
|
);
|
|
337
340
|
serverUrl = (serverInput || currentServer).replace(/\/+$/, '');
|
|
338
341
|
}
|
|
342
|
+
if (!/^https?:\/\//i.test(serverUrl)) serverUrl = `http://${serverUrl}`;
|
|
343
|
+
try { new URL(serverUrl); } catch { error(`Invalid server URL: ${serverUrl}`); process.exit(1); }
|
|
339
344
|
info(` Server: ${serverUrl}`);
|
|
340
345
|
|
|
341
346
|
// Project filter
|
|
@@ -350,7 +355,24 @@ export default async function init() {
|
|
|
350
355
|
const projectsInput = await ask(
|
|
351
356
|
`Projects to track (comma-separated, ~ supported, Enter = ${projectsDefault}): `,
|
|
352
357
|
);
|
|
353
|
-
projects = projectsInput
|
|
358
|
+
projects = (projectsInput && projectsInput.trim() && projectsInput.trim().toLowerCase() !== 'all')
|
|
359
|
+
? projectsInput
|
|
360
|
+
: null;
|
|
361
|
+
}
|
|
362
|
+
// Guard: non-string (e.g. array from corrupt config) → treat as unset
|
|
363
|
+
if (projects && typeof projects !== 'string') {
|
|
364
|
+
projects = null;
|
|
365
|
+
}
|
|
366
|
+
// Guard: "all" means monitor everything → null
|
|
367
|
+
if (typeof projects === 'string' && projects.trim().toLowerCase() === 'all') {
|
|
368
|
+
projects = null;
|
|
369
|
+
}
|
|
370
|
+
// Normalize: resolve relative paths to absolute, expand ~
|
|
371
|
+
if (projects) {
|
|
372
|
+
const home = homedir();
|
|
373
|
+
projects = projects.split(',').map(p => p.trim()).filter(Boolean)
|
|
374
|
+
.map(p => p.startsWith('~/') ? join(home, p.slice(2)) : resolve(p))
|
|
375
|
+
.join(',');
|
|
354
376
|
}
|
|
355
377
|
if (projects) {
|
|
356
378
|
info(` Tracking: ${projects}`);
|
|
@@ -358,23 +380,35 @@ export default async function init() {
|
|
|
358
380
|
info(' Tracking: all projects');
|
|
359
381
|
}
|
|
360
382
|
|
|
361
|
-
//
|
|
383
|
+
// Build new config in memory — saved after "Proceed?" confirmation
|
|
362
384
|
const newConfig = { ...currentConfig, serverUrl, projects };
|
|
363
|
-
|
|
364
|
-
|
|
385
|
+
blank();
|
|
386
|
+
|
|
387
|
+
// Install client files to ~/.ai-lens/client/
|
|
388
|
+
heading('Installing client files...');
|
|
389
|
+
try {
|
|
390
|
+
installClientFiles();
|
|
391
|
+
success(' Copied client files to ~/.ai-lens/client/');
|
|
392
|
+
} catch (err) {
|
|
393
|
+
error(` Failed to install client files: ${err.message}`);
|
|
394
|
+
return;
|
|
365
395
|
}
|
|
366
396
|
blank();
|
|
367
397
|
|
|
368
398
|
// Authentication
|
|
369
399
|
heading('Authentication');
|
|
370
400
|
if (currentConfig.authToken) {
|
|
371
|
-
const
|
|
372
|
-
if (valid) {
|
|
401
|
+
const tokenStatus = await validateExistingToken(serverUrl, currentConfig.authToken);
|
|
402
|
+
if (tokenStatus === 'valid') {
|
|
373
403
|
success(' Already authenticated (token verified)');
|
|
374
|
-
} else {
|
|
404
|
+
} else if (tokenStatus === 'unknown') {
|
|
405
|
+
warn(' Token format not recognized — keeping existing token');
|
|
406
|
+
} else if (tokenStatus === 'invalid') {
|
|
375
407
|
warn(' Existing token is invalid or revoked — re-authenticating...');
|
|
376
408
|
currentConfig.authToken = null;
|
|
377
409
|
newConfig.authToken = null;
|
|
410
|
+
} else {
|
|
411
|
+
warn(' Could not reach server to verify token — keeping existing token');
|
|
378
412
|
}
|
|
379
413
|
}
|
|
380
414
|
if (!currentConfig.authToken) {
|
|
@@ -387,11 +421,23 @@ export default async function init() {
|
|
|
387
421
|
if (err.message.includes('not configured')) {
|
|
388
422
|
warn(` Auth not configured on server — personal mode (events sent via git identity)`);
|
|
389
423
|
} else {
|
|
390
|
-
|
|
391
|
-
|
|
424
|
+
warn(` Authentication failed: ${err.message}`);
|
|
425
|
+
warn(` Run "npx -y ai-lens init" again later to authenticate`);
|
|
392
426
|
}
|
|
393
427
|
}
|
|
394
428
|
}
|
|
429
|
+
|
|
430
|
+
// Validate identity: no token + no git email = events will be dropped
|
|
431
|
+
if (!newConfig.authToken) {
|
|
432
|
+
const { email } = getGitIdentity();
|
|
433
|
+
if (!email) {
|
|
434
|
+
blank();
|
|
435
|
+
error(' No auth token and no git email configured.');
|
|
436
|
+
error(' Events will be silently dropped until one is available.');
|
|
437
|
+
info(' Fix: git config --global user.email "you@example.com"');
|
|
438
|
+
info(' Or re-run init when Auth0 is configured on the server.');
|
|
439
|
+
}
|
|
440
|
+
}
|
|
395
441
|
blank();
|
|
396
442
|
|
|
397
443
|
// Analyze each tool
|
|
@@ -423,15 +469,17 @@ export default async function init() {
|
|
|
423
469
|
// Filter to tools that need changes
|
|
424
470
|
const pending = analyses.filter(a => a.analysis.status !== 'current');
|
|
425
471
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
472
|
+
if (pending.length === 0) {
|
|
473
|
+
saveLensConfig(newConfig);
|
|
474
|
+
|
|
475
|
+
// Clean up legacy hook locations (safe: hooks are already current)
|
|
476
|
+
for (const { tool } of analyses) {
|
|
477
|
+
for (const lr of cleanupLegacyHooks(tool)) {
|
|
478
|
+
success(` ${tool.name}: ${lr.action} legacy hooks in ${lr.path}`);
|
|
479
|
+
}
|
|
430
480
|
}
|
|
431
|
-
}
|
|
432
481
|
|
|
433
|
-
|
|
434
|
-
success('Everything is up-to-date. Nothing to do.');
|
|
482
|
+
success('Hooks are up-to-date.');
|
|
435
483
|
} else {
|
|
436
484
|
// Show plan
|
|
437
485
|
heading('Plan:');
|
|
@@ -453,16 +501,15 @@ export default async function init() {
|
|
|
453
501
|
}
|
|
454
502
|
blank();
|
|
455
503
|
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
504
|
+
// Persist config only after confirmation
|
|
505
|
+
saveLensConfig(newConfig);
|
|
506
|
+
|
|
507
|
+
// Clean up legacy hook locations before applying new ones
|
|
508
|
+
for (const { tool } of analyses) {
|
|
509
|
+
for (const lr of cleanupLegacyHooks(tool)) {
|
|
510
|
+
success(` ${tool.name}: ${lr.action} legacy hooks in ${lr.path}`);
|
|
511
|
+
}
|
|
464
512
|
}
|
|
465
|
-
blank();
|
|
466
513
|
|
|
467
514
|
// Apply
|
|
468
515
|
heading('Applying changes...');
|
|
@@ -470,6 +517,10 @@ export default async function init() {
|
|
|
470
517
|
|
|
471
518
|
for (const { tool, analysis } of pending) {
|
|
472
519
|
try {
|
|
520
|
+
// Backup malformed shared configs (e.g. ~/.claude/settings.json) before overwriting
|
|
521
|
+
if (analysis.status === 'malformed' && tool.sharedConfig) {
|
|
522
|
+
try { copyFileSync(tool.configPath, tool.configPath + '.bak'); } catch { /* file may be gone */ }
|
|
523
|
+
}
|
|
473
524
|
const existingConfig = analysis.config || null;
|
|
474
525
|
const merged = buildMergedConfig(tool, existingConfig);
|
|
475
526
|
writeHooksConfig(tool, merged);
|
|
@@ -484,12 +535,14 @@ export default async function init() {
|
|
|
484
535
|
|
|
485
536
|
// Verify hooks were written correctly
|
|
486
537
|
heading('Verifying hooks...');
|
|
538
|
+
let verifyFailed = false;
|
|
487
539
|
for (const { tool } of pending) {
|
|
488
540
|
const recheck = analyzeToolHooks(tool);
|
|
489
541
|
if (recheck.status === 'current') {
|
|
490
542
|
success(` ${tool.name}: hooks verified`);
|
|
491
543
|
} else {
|
|
492
544
|
error(` ${tool.name}: hooks not current (status: ${recheck.status})`);
|
|
545
|
+
verifyFailed = true;
|
|
493
546
|
}
|
|
494
547
|
}
|
|
495
548
|
blank();
|
|
@@ -503,6 +556,9 @@ export default async function init() {
|
|
|
503
556
|
error(` ${r.tool}: failed (${r.error})`);
|
|
504
557
|
}
|
|
505
558
|
}
|
|
559
|
+
if (results.some(r => !r.ok) || verifyFailed) {
|
|
560
|
+
process.exitCode = 1;
|
|
561
|
+
}
|
|
506
562
|
blank();
|
|
507
563
|
}
|
|
508
564
|
|
package/client/capture.js
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
CAPTURE_LOG_PATH,
|
|
21
21
|
captureLog,
|
|
22
22
|
getServerUrl,
|
|
23
|
+
getAuthToken,
|
|
23
24
|
getGitIdentity,
|
|
24
25
|
getGitMetadata,
|
|
25
26
|
getMonitoredProjects,
|
|
@@ -40,6 +41,17 @@ function logDrop(reason, meta = {}) {
|
|
|
40
41
|
} catch { /* best-effort */ }
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
// =============================================================================
|
|
45
|
+
// Identity Resolution
|
|
46
|
+
// =============================================================================
|
|
47
|
+
|
|
48
|
+
export function resolveIdentity(gitIdentity, event, hasAuthToken) {
|
|
49
|
+
const email = gitIdentity.email || event.user_email || null;
|
|
50
|
+
if (!email && !hasAuthToken) return { proceed: false, email: null, name: null };
|
|
51
|
+
const name = gitIdentity.name || event.user_name || email || null;
|
|
52
|
+
return { proceed: true, email, name };
|
|
53
|
+
}
|
|
54
|
+
|
|
43
55
|
// =============================================================================
|
|
44
56
|
// Truncation (reused from ai-session-lens prompts.js approach)
|
|
45
57
|
// =============================================================================
|
|
@@ -161,20 +173,27 @@ function saveLastEvents(cache) {
|
|
|
161
173
|
}
|
|
162
174
|
|
|
163
175
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
176
|
+
* Pure check — returns true if this event is a duplicate that should be dropped.
|
|
177
|
+
* Does NOT update the cache. Call commitDedup() after successful queue write.
|
|
166
178
|
*/
|
|
167
|
-
export function
|
|
179
|
+
export function checkDuplicate(sessionId, source, type) {
|
|
168
180
|
const cache = loadLastEvents();
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
181
|
+
const key = `${source}:${sessionId}`;
|
|
182
|
+
const prev = cache[key];
|
|
183
|
+
return DEDUP_TYPES.has(type) && prev === type;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Commit the event type to the dedup cache.
|
|
188
|
+
* Call only after successful queue write to avoid cache poisoning.
|
|
189
|
+
*/
|
|
190
|
+
export function commitDedup(sessionId, source, type) {
|
|
191
|
+
const cache = loadLastEvents();
|
|
192
|
+
const key = `${source}:${sessionId}`;
|
|
193
|
+
if (cache[key] !== type) {
|
|
194
|
+
cache[key] = type;
|
|
195
|
+
try { saveLastEvents(cache); } catch { /* best effort */ }
|
|
176
196
|
}
|
|
177
|
-
return dominated;
|
|
178
197
|
}
|
|
179
198
|
|
|
180
199
|
// =============================================================================
|
|
@@ -330,12 +349,27 @@ const CURSOR_TYPE_MAP = {
|
|
|
330
349
|
sessionEnd: 'SessionEnd',
|
|
331
350
|
};
|
|
332
351
|
|
|
352
|
+
function pickWorkspaceRoot(roots) {
|
|
353
|
+
if (!Array.isArray(roots) || roots.length === 0) return null;
|
|
354
|
+
const valid = roots.filter(r => typeof r === 'string' && r.length > 0);
|
|
355
|
+
if (valid.length === 0) return null;
|
|
356
|
+
if (valid.length === 1) return valid[0];
|
|
357
|
+
const monitored = getMonitoredProjects();
|
|
358
|
+
if (monitored) {
|
|
359
|
+
const match = valid.find(root =>
|
|
360
|
+
monitored.some(p => root === p || root.startsWith(p + '/'))
|
|
361
|
+
);
|
|
362
|
+
if (match) return match;
|
|
363
|
+
}
|
|
364
|
+
return valid[0];
|
|
365
|
+
}
|
|
366
|
+
|
|
333
367
|
function normalizeCursor(event) {
|
|
334
368
|
const sessionId = event.conversation_id || null;
|
|
335
369
|
const hookName = event.hook_event_name;
|
|
336
370
|
const type = CURSOR_TYPE_MAP[hookName] || hookName;
|
|
337
371
|
const timestamp = new Date().toISOString();
|
|
338
|
-
let projectPath =
|
|
372
|
+
let projectPath = pickWorkspaceRoot(event.workspace_roots);
|
|
339
373
|
if (projectPath) {
|
|
340
374
|
cacheSessionPath(sessionId, projectPath);
|
|
341
375
|
} else {
|
|
@@ -357,10 +391,12 @@ function normalizeCursor(event) {
|
|
|
357
391
|
input: truncateToolInput(event.tool_input, toolName),
|
|
358
392
|
result: truncateToolResult(event.tool_result, toolName),
|
|
359
393
|
};
|
|
394
|
+
const mcpServer = event.mcp_server || (toolName.startsWith('mcp__') ? toolName.split('__')[1] : null);
|
|
395
|
+
if (mcpServer) data.mcp_server = mcpServer;
|
|
360
396
|
break;
|
|
361
397
|
}
|
|
362
398
|
case 'afterFileEdit':
|
|
363
|
-
data = { file_path: event.file_path, edits: event.edits };
|
|
399
|
+
data = { file_path: event.file_path, edits: truncateToolResult(event.edits, 'default') };
|
|
364
400
|
break;
|
|
365
401
|
case 'afterShellExecution':
|
|
366
402
|
data = {
|
|
@@ -371,16 +407,18 @@ function normalizeCursor(event) {
|
|
|
371
407
|
case 'postToolUseFailure': {
|
|
372
408
|
const failToolName = event.tool_name || 'unknown';
|
|
373
409
|
data = {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
failure_type: event.failure_type
|
|
410
|
+
tool: failToolName,
|
|
411
|
+
input: truncateToolInput(event.tool_input, failToolName),
|
|
412
|
+
error: truncate(event.error_message || '', 300),
|
|
413
|
+
failure_type: event.failure_type ?? null,
|
|
378
414
|
duration: event.duration ?? null,
|
|
379
415
|
};
|
|
416
|
+
const failMcp = event.mcp_server || (failToolName.startsWith('mcp__') ? failToolName.split('__')[1] : null);
|
|
417
|
+
if (failMcp) data.mcp_server = failMcp;
|
|
380
418
|
break;
|
|
381
419
|
}
|
|
382
420
|
case 'afterMCPExecution':
|
|
383
|
-
data = { mcp_server: event.mcp_server, result: event.result };
|
|
421
|
+
data = { mcp_server: event.mcp_server, result: truncateToolResult(event.result, 'default') };
|
|
384
422
|
break;
|
|
385
423
|
case 'subagentStart':
|
|
386
424
|
data = {
|
|
@@ -486,7 +524,7 @@ async function main() {
|
|
|
486
524
|
// Check server is configured
|
|
487
525
|
const serverUrl = getServerUrl();
|
|
488
526
|
if (!serverUrl) {
|
|
489
|
-
|
|
527
|
+
logDrop('no_server_url');
|
|
490
528
|
process.exit(0);
|
|
491
529
|
}
|
|
492
530
|
|
|
@@ -499,6 +537,7 @@ async function main() {
|
|
|
499
537
|
}
|
|
500
538
|
|
|
501
539
|
if (!input.trim()) {
|
|
540
|
+
logDrop('empty_stdin');
|
|
502
541
|
process.exit(0);
|
|
503
542
|
}
|
|
504
543
|
|
|
@@ -506,7 +545,7 @@ async function main() {
|
|
|
506
545
|
try {
|
|
507
546
|
event = JSON.parse(input);
|
|
508
547
|
} catch {
|
|
509
|
-
|
|
548
|
+
logDrop('malformed_json', { input_length: input.length, first_chars: input.slice(0, 100) });
|
|
510
549
|
process.exit(0);
|
|
511
550
|
}
|
|
512
551
|
|
|
@@ -516,28 +555,35 @@ async function main() {
|
|
|
516
555
|
process.exit(0);
|
|
517
556
|
}
|
|
518
557
|
|
|
519
|
-
// Deduplicate consecutive identical event types (e.g. repeated Stop from idle sessions)
|
|
520
|
-
if (isDuplicateEvent(unified.session_id, unified.type)) {
|
|
521
|
-
logDrop('duplicate', { type: unified.type, session_id: unified.session_id });
|
|
522
|
-
process.exit(0);
|
|
523
|
-
}
|
|
524
|
-
|
|
525
558
|
// Filter by monitored projects (if configured)
|
|
526
559
|
const monitored = getMonitoredProjects();
|
|
527
|
-
if (monitored && !monitored.some(p => unified.project_path === p || unified.project_path
|
|
528
|
-
|
|
529
|
-
|
|
560
|
+
if (monitored && unified.project_path && !monitored.some(p => unified.project_path === p || unified.project_path.startsWith(p + '/'))) {
|
|
561
|
+
// Fallback: for Cursor multi-root workspaces, check if any raw workspace_roots entry matches
|
|
562
|
+
const roots = Array.isArray(event.workspace_roots) ? event.workspace_roots : [];
|
|
563
|
+
if (!roots.some(root => monitored.some(p => root === p || root.startsWith(p + '/')))) {
|
|
564
|
+
logDrop('project_filter', { type: unified.type, source: unified.source, session_id: unified.session_id, project_path: unified.project_path, monitored });
|
|
565
|
+
process.exit(0);
|
|
566
|
+
}
|
|
530
567
|
}
|
|
531
568
|
|
|
532
569
|
// Resolve identity: git first, then fall back to event payload (e.g. Cursor's user_email)
|
|
570
|
+
// When auth token is present, server resolves developer from token — email is optional
|
|
533
571
|
const identity = getGitIdentity();
|
|
534
|
-
const
|
|
535
|
-
|
|
572
|
+
const hasAuthToken = !!getAuthToken();
|
|
573
|
+
const resolved = resolveIdentity(identity, event, hasAuthToken);
|
|
574
|
+
if (!resolved.proceed) {
|
|
536
575
|
logDrop('no_email', { type: unified.type, session_id: unified.session_id });
|
|
537
576
|
process.exit(0);
|
|
538
577
|
}
|
|
539
|
-
|
|
540
|
-
|
|
578
|
+
|
|
579
|
+
// Deduplicate consecutive identical event types (e.g. repeated Stop from idle sessions)
|
|
580
|
+
// Placed after project_filter and no_email checks so dropped events don't poison the cache
|
|
581
|
+
if (checkDuplicate(unified.session_id, unified.source, unified.type)) {
|
|
582
|
+
logDrop('duplicate', { type: unified.type, session_id: unified.session_id });
|
|
583
|
+
process.exit(0);
|
|
584
|
+
}
|
|
585
|
+
unified.developer_email = resolved.email;
|
|
586
|
+
unified.developer_name = resolved.name;
|
|
541
587
|
|
|
542
588
|
// Attach git metadata (remote, branch, commit)
|
|
543
589
|
const gitMeta = getGitMetadata(unified.project_path);
|
|
@@ -553,6 +599,9 @@ async function main() {
|
|
|
553
599
|
process.exit(1);
|
|
554
600
|
}
|
|
555
601
|
|
|
602
|
+
// Commit dedup cache only after successful queue write (avoids cache poisoning on write failure)
|
|
603
|
+
commitDedup(unified.session_id, unified.source, unified.type);
|
|
604
|
+
|
|
556
605
|
// Always try to spawn sender — atomic rename in sender handles dedup
|
|
557
606
|
try {
|
|
558
607
|
trySpawnSender();
|
package/client/config.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mkdirSync, appendFileSync, readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { execSync } from 'node:child_process';
|
|
5
5
|
|
|
@@ -30,7 +30,11 @@ function loadConfig() {
|
|
|
30
30
|
if (_configCache !== undefined) return _configCache;
|
|
31
31
|
try {
|
|
32
32
|
_configCache = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
|
33
|
-
} catch {
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (err.code !== 'ENOENT') {
|
|
35
|
+
captureLog({ msg: 'config-parse-error', path: CONFIG_PATH, error: err.message });
|
|
36
|
+
console.error(`[ai-lens] config.json corrupt, falling back to defaults: ${err.message}`);
|
|
37
|
+
}
|
|
34
38
|
_configCache = {};
|
|
35
39
|
}
|
|
36
40
|
return _configCache;
|
|
@@ -54,9 +58,14 @@ export function getServerUrl() {
|
|
|
54
58
|
export function getMonitoredProjects() {
|
|
55
59
|
const val = process.env.AI_LENS_PROJECTS || loadConfig().projects;
|
|
56
60
|
if (!val) return null; // null = monitor everything
|
|
61
|
+
if (typeof val !== 'string') return null; // non-string (e.g. array) = treat as unset
|
|
62
|
+
if (val.trim().toLowerCase() === 'all') return null;
|
|
63
|
+
const paths = val.split(',').map(p => p.trim()).filter(Boolean);
|
|
64
|
+
if (paths.length === 0) return null;
|
|
57
65
|
const home = homedir();
|
|
58
|
-
return
|
|
66
|
+
return paths
|
|
59
67
|
.map(p => p.startsWith('~/') ? join(home, p.slice(2)) : p)
|
|
68
|
+
.map(p => resolve(p))
|
|
60
69
|
.map(p => p.endsWith('/') ? p.slice(0, -1) : p);
|
|
61
70
|
}
|
|
62
71
|
|
|
@@ -70,14 +79,14 @@ export function getGitIdentity() {
|
|
|
70
79
|
|
|
71
80
|
try {
|
|
72
81
|
email = execSync('git config user.email', { encoding: 'utf-8', timeout: 3000 }).trim();
|
|
73
|
-
} catch {
|
|
74
|
-
|
|
82
|
+
} catch (err) {
|
|
83
|
+
captureLog({ msg: 'git-email-failed', error: err.message?.split('\n')[0] });
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
try {
|
|
78
87
|
name = execSync('git config user.name', { encoding: 'utf-8', timeout: 3000 }).trim();
|
|
79
88
|
} catch {
|
|
80
|
-
// git
|
|
89
|
+
// git name missing is non-critical — email or token is sufficient
|
|
81
90
|
}
|
|
82
91
|
|
|
83
92
|
return { email, name };
|
|
@@ -112,7 +121,9 @@ function cacheRemote(projectPath, remote) {
|
|
|
112
121
|
const remotes = loadGitRemotes();
|
|
113
122
|
if (remotes[projectPath] !== remote) {
|
|
114
123
|
remotes[projectPath] = remote;
|
|
115
|
-
|
|
124
|
+
try {
|
|
125
|
+
saveGitRemotes(remotes);
|
|
126
|
+
} catch { /* cache write failed — event proceeds without cached remote */ }
|
|
116
127
|
}
|
|
117
128
|
}
|
|
118
129
|
|
package/client/redact.js
CHANGED
|
@@ -34,11 +34,20 @@ const PATTERNS = [
|
|
|
34
34
|
{ type: 'JWT', re: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_\-.+/=]{10,}/g },
|
|
35
35
|
|
|
36
36
|
// PEM private keys — full block or truncated (just the header + base64 content)
|
|
37
|
-
{ type: 'PRIVATE_KEY', re: /-----BEGIN[A-Z ]*PRIVATE KEY-----[
|
|
37
|
+
{ type: 'PRIVATE_KEY', re: /-----BEGIN[A-Z ]*PRIVATE KEY-----[A-Za-z0-9+/=\s\\.]*(?:-----END[A-Z ]*PRIVATE KEY-----)?/g },
|
|
38
38
|
|
|
39
39
|
// Connection string password (://user:password@host) — redacts password only
|
|
40
40
|
{ type: 'CONNECTION_STRING', re: /:\/\/([^:@\s]+):([^@\s]{3,})@/g, replacer: (m, user, _pw) => `://${user}:[REDACTED:CONNECTION_STRING]@` },
|
|
41
41
|
|
|
42
|
+
// Environment variables: UPPER_CASE_VAR with secret keyword = value
|
|
43
|
+
// Catches AI_LENS_AUTH_TOKEN=..., PGPASSWORD=..., AWS_SECRET_ACCESS_KEY=..., etc.
|
|
44
|
+
// Skips template refs like ${VAR} since value excludes { } chars.
|
|
45
|
+
{
|
|
46
|
+
type: 'ENV_VAR',
|
|
47
|
+
re: /([A-Z_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API_KEY|APIKEY)[A-Z_]*\s*=\s*["']?)([^\s"';\\\`|>{}\[\]]{8,})/g,
|
|
48
|
+
replacer: (m, prefix, _val) => `${prefix}[REDACTED:ENV_VAR]`,
|
|
49
|
+
},
|
|
50
|
+
|
|
42
51
|
// Key-value pairs: password=..., token: ..., etc.
|
|
43
52
|
{
|
|
44
53
|
type: 'KEY_VALUE',
|
package/client/sender.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - Rollback on failure: unsent events prepended back to queue.jsonl
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { readFileSync, writeFileSync, unlinkSync, renameSync } from 'node:fs';
|
|
13
|
+
import { readFileSync, writeFileSync, appendFileSync, unlinkSync, renameSync } from 'node:fs';
|
|
14
14
|
import { request as httpsRequest } from 'node:https';
|
|
15
15
|
import { request as httpRequest } from 'node:http';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
@@ -56,19 +56,32 @@ export function parseQueueContent(content) {
|
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
58
|
* Group events by developer_email.
|
|
59
|
-
* Events without developer_email are skipped
|
|
60
|
-
*
|
|
59
|
+
* Events without developer_email are skipped unless hasAuthToken is true,
|
|
60
|
+
* in which case they are grouped under a special '__token_auth__' key
|
|
61
|
+
* (server resolves identity from the token).
|
|
62
|
+
* Returns Map<email|'__token_auth__', { identity: { email, name }, events: [] }>
|
|
61
63
|
*/
|
|
62
|
-
export function groupByDeveloper(events) {
|
|
64
|
+
export function groupByDeveloper(events, hasAuthToken = false) {
|
|
65
|
+
const TOKEN_KEY = '__token_auth__';
|
|
63
66
|
const byDeveloper = new Map();
|
|
67
|
+
let skippedNoEmail = 0;
|
|
64
68
|
for (const evt of events) {
|
|
65
69
|
const email = evt.developer_email;
|
|
66
|
-
if (!email)
|
|
70
|
+
if (!email) {
|
|
71
|
+
if (!hasAuthToken) { skippedNoEmail++; continue; }
|
|
72
|
+
if (!byDeveloper.has(TOKEN_KEY))
|
|
73
|
+
byDeveloper.set(TOKEN_KEY, { identity: { email: null, name: null }, events: [] });
|
|
74
|
+
byDeveloper.get(TOKEN_KEY).events.push(evt);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
67
77
|
if (!byDeveloper.has(email)) {
|
|
68
78
|
byDeveloper.set(email, { identity: { email, name: evt.developer_name || email }, events: [] });
|
|
69
79
|
}
|
|
70
80
|
byDeveloper.get(email).events.push(evt);
|
|
71
81
|
}
|
|
82
|
+
if (skippedNoEmail > 0) {
|
|
83
|
+
log({ msg: 'skip-no-email', skipped: skippedNoEmail, total: events.length });
|
|
84
|
+
}
|
|
72
85
|
return byDeveloper;
|
|
73
86
|
}
|
|
74
87
|
|
|
@@ -79,6 +92,41 @@ export function buildRollbackContent(unsentContent, existingContent) {
|
|
|
79
92
|
return unsentContent + existingContent;
|
|
80
93
|
}
|
|
81
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Check if a sender lock file is stale (owner process no longer running).
|
|
97
|
+
* Returns true if lock is missing or process is dead.
|
|
98
|
+
*/
|
|
99
|
+
export function isLockStale(lockPath) {
|
|
100
|
+
try {
|
|
101
|
+
const pid = parseInt(readFileSync(lockPath, 'utf-8').trim(), 10);
|
|
102
|
+
process.kill(pid, 0); // throws if process doesn't exist
|
|
103
|
+
return false; // process alive — lock is active
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (err.code === 'ESRCH') return true; // process dead — stale
|
|
106
|
+
if (err.code === 'ENOENT') return true; // no lock file
|
|
107
|
+
return false; // permission error etc — assume active
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Merge unsent content back into the queue without losing concurrent appends.
|
|
113
|
+
* Uses atomic rename to drain current queue before merging.
|
|
114
|
+
*/
|
|
115
|
+
export function mergeToQueue(unsentContent, queuePath) {
|
|
116
|
+
const tmpPath = queuePath + '.rollback.' + process.pid;
|
|
117
|
+
writeFileSync(tmpPath, unsentContent);
|
|
118
|
+
const drainPath = queuePath + '.drain.' + process.pid;
|
|
119
|
+
try {
|
|
120
|
+
renameSync(queuePath, drainPath);
|
|
121
|
+
appendFileSync(tmpPath, readFileSync(drainPath, 'utf-8'));
|
|
122
|
+
unlinkSync(drainPath);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (err.code !== 'ENOENT') throw err;
|
|
125
|
+
// No queue existed — nothing to drain
|
|
126
|
+
}
|
|
127
|
+
renameSync(tmpPath, queuePath);
|
|
128
|
+
}
|
|
129
|
+
|
|
82
130
|
/**
|
|
83
131
|
* Atomically acquire the queue for sending.
|
|
84
132
|
* renameSync is atomic on POSIX — acts as a mutex.
|
|
@@ -89,16 +137,22 @@ export function buildRollbackContent(unsentContent, existingContent) {
|
|
|
89
137
|
* @param {string} [sendingPath] - Override sending path (for testing); defaults to SENDING_PATH
|
|
90
138
|
*/
|
|
91
139
|
export function acquireQueue(queuePath = QUEUE_PATH, sendingPath = SENDING_PATH) {
|
|
92
|
-
|
|
140
|
+
const lockPath = sendingPath + '.lock';
|
|
141
|
+
|
|
142
|
+
// Recover orphaned sending file from a previously crashed sender.
|
|
143
|
+
// Only recover if the lock is stale (owner process is dead).
|
|
93
144
|
try {
|
|
94
145
|
const orphaned = readFileSync(sendingPath, 'utf-8');
|
|
146
|
+
if (!isLockStale(lockPath)) {
|
|
147
|
+
// Another sender is actively working — exit without touching its file
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
95
150
|
if (orphaned.trim()) {
|
|
96
|
-
|
|
97
|
-
try { existing = readFileSync(queuePath, 'utf-8'); } catch { /* no queue yet */ }
|
|
98
|
-
writeFileSync(queuePath, orphaned + existing);
|
|
151
|
+
mergeToQueue(orphaned, queuePath);
|
|
99
152
|
log({ msg: 'orphan-recovered', bytes: Buffer.byteLength(orphaned) });
|
|
100
153
|
}
|
|
101
154
|
unlinkSync(sendingPath);
|
|
155
|
+
try { unlinkSync(lockPath); } catch { /* stale lock already gone */ }
|
|
102
156
|
} catch (err) {
|
|
103
157
|
if (err.code !== 'ENOENT') throw err;
|
|
104
158
|
// No orphan — normal path
|
|
@@ -111,12 +165,16 @@ export function acquireQueue(queuePath = QUEUE_PATH, sendingPath = SENDING_PATH)
|
|
|
111
165
|
throw err;
|
|
112
166
|
}
|
|
113
167
|
|
|
168
|
+
// Write PID lock so other senders know we're active
|
|
169
|
+
writeFileSync(lockPath, String(process.pid));
|
|
170
|
+
|
|
114
171
|
const content = readFileSync(sendingPath, 'utf-8');
|
|
115
172
|
const { events, dropped, overflow } = parseQueueContent(content);
|
|
116
173
|
if (dropped > 0) log({ msg: 'queue-corruption', dropped });
|
|
117
174
|
if (overflow > 0) log({ msg: 'queue-overflow', dropped: overflow, kept: MAX_QUEUE_SIZE });
|
|
118
175
|
if (events.length === 0) {
|
|
119
176
|
unlinkSync(sendingPath);
|
|
177
|
+
try { unlinkSync(lockPath); } catch {}
|
|
120
178
|
return null;
|
|
121
179
|
}
|
|
122
180
|
|
|
@@ -128,6 +186,7 @@ export function acquireQueue(queuePath = QUEUE_PATH, sendingPath = SENDING_PATH)
|
|
|
128
186
|
*/
|
|
129
187
|
function commitQueue(sendingPath) {
|
|
130
188
|
try { unlinkSync(sendingPath); } catch { /* already gone */ }
|
|
189
|
+
try { unlinkSync(sendingPath + '.lock'); } catch { /* already gone */ }
|
|
131
190
|
}
|
|
132
191
|
|
|
133
192
|
/**
|
|
@@ -137,10 +196,9 @@ function commitQueue(sendingPath) {
|
|
|
137
196
|
function rollbackQueue(sendingPath, eventCount) {
|
|
138
197
|
try {
|
|
139
198
|
const unsent = readFileSync(sendingPath, 'utf-8');
|
|
140
|
-
|
|
141
|
-
try { existing = readFileSync(QUEUE_PATH, 'utf-8'); } catch { /* no new events */ }
|
|
142
|
-
writeFileSync(QUEUE_PATH, buildRollbackContent(unsent, existing));
|
|
199
|
+
mergeToQueue(unsent, QUEUE_PATH);
|
|
143
200
|
unlinkSync(sendingPath);
|
|
201
|
+
try { unlinkSync(sendingPath + '.lock'); } catch {}
|
|
144
202
|
log({ msg: 'rollback', events: eventCount });
|
|
145
203
|
} catch { /* best effort */ }
|
|
146
204
|
}
|
|
@@ -155,13 +213,12 @@ function rollbackQueue(sendingPath, eventCount) {
|
|
|
155
213
|
*/
|
|
156
214
|
export function partialRollback(sendingPath, unsentEvents, totalCount, queuePath = QUEUE_PATH) {
|
|
157
215
|
try {
|
|
158
|
-
let existing = '';
|
|
159
|
-
try { existing = readFileSync(queuePath, 'utf-8'); } catch { /* no new events */ }
|
|
160
216
|
if (unsentEvents.length > 0) {
|
|
161
217
|
const unsentContent = unsentEvents.map(e => JSON.stringify(e)).join('\n') + '\n';
|
|
162
|
-
|
|
218
|
+
mergeToQueue(unsentContent, queuePath);
|
|
163
219
|
}
|
|
164
220
|
try { unlinkSync(sendingPath); } catch { /* already gone */ }
|
|
221
|
+
try { unlinkSync(sendingPath + '.lock'); } catch { /* already gone */ }
|
|
165
222
|
log({ msg: 'partial-rollback', sent: totalCount - unsentEvents.length, unsent: unsentEvents.length });
|
|
166
223
|
} catch (rollbackErr) {
|
|
167
224
|
log({ msg: 'rollback-failed', error: rollbackErr.message });
|
|
@@ -207,16 +264,16 @@ export function chunkEvents(events, maxBytes = MAX_CHUNK_BYTES) {
|
|
|
207
264
|
function postEvents(serverUrl, events, identity) {
|
|
208
265
|
return new Promise((resolve, reject) => {
|
|
209
266
|
const body = JSON.stringify(events);
|
|
210
|
-
const url = new URL(
|
|
267
|
+
const url = new URL(`${serverUrl}/api/events`);
|
|
211
268
|
const isHttps = url.protocol === 'https:';
|
|
212
269
|
const requestFn = isHttps ? httpsRequest : httpRequest;
|
|
213
270
|
|
|
214
271
|
const headers = {
|
|
215
272
|
'Content-Type': 'application/json',
|
|
216
273
|
'Content-Length': Buffer.byteLength(body),
|
|
217
|
-
'X-Developer-Git-Email': identity.email,
|
|
218
|
-
'X-Developer-Name': encodeURIComponent(identity.name),
|
|
219
274
|
};
|
|
275
|
+
if (identity.email) headers['X-Developer-Git-Email'] = identity.email;
|
|
276
|
+
if (identity.name) headers['X-Developer-Name'] = encodeURIComponent(identity.name);
|
|
220
277
|
|
|
221
278
|
const authToken = getAuthToken();
|
|
222
279
|
if (authToken) {
|
|
@@ -269,9 +326,12 @@ async function main() {
|
|
|
269
326
|
const { events, sendingPath } = acquired;
|
|
270
327
|
|
|
271
328
|
// Group events by developer_email (baked in at capture time)
|
|
272
|
-
|
|
329
|
+
// When auth token is present, null-email events are grouped for token-based identity
|
|
330
|
+
const hasAuthToken = !!getAuthToken();
|
|
331
|
+
const byDeveloper = groupByDeveloper(events, hasAuthToken);
|
|
273
332
|
|
|
274
333
|
if (byDeveloper.size === 0) {
|
|
334
|
+
log({ msg: 'queue-empty-after-grouping', total_events: events.length, has_auth_token: hasAuthToken });
|
|
275
335
|
commitQueue(sendingPath);
|
|
276
336
|
process.exit(0);
|
|
277
337
|
}
|
|
@@ -296,6 +356,9 @@ async function main() {
|
|
|
296
356
|
for (const chunk of chunks) {
|
|
297
357
|
const result = await postEvents(serverUrl, chunk, identity);
|
|
298
358
|
totalReceived += result.received;
|
|
359
|
+
if (result.skipped > 0) {
|
|
360
|
+
log({ msg: 'server-skipped', skipped: result.skipped, chunk_size: chunk.length, developer: identity.email });
|
|
361
|
+
}
|
|
299
362
|
for (const evt of chunk) {
|
|
300
363
|
if (evt.event_id) sentEventIds.add(evt.event_id);
|
|
301
364
|
}
|