ai-lens 0.7.2 → 0.7.3

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 CHANGED
@@ -1 +1 @@
1
- 13ac046
1
+ 75f2d5f
package/README.md CHANGED
@@ -11,7 +11,7 @@ Hook fires → capture.js → normalize → queue.jsonl → sender.js → POST /
11
11
  Run the init command on each developer machine:
12
12
 
13
13
  ```bash
14
- npx ai-lens init
14
+ npx -y ai-lens init
15
15
  ```
16
16
 
17
17
  This will:
package/cli/init.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  initLogger, info, success, warn, error,
10
10
  heading, detail, blank, getLogPath,
11
11
  } from './logger.js';
12
+ import { getGitIdentity } from '../client/config.js';
12
13
  import {
13
14
  CAPTURE_PATH, detectInstalledTools,
14
15
  analyzeToolHooks, buildMergedConfig, writeHooksConfig, describePlan,
@@ -226,6 +227,38 @@ async function deviceCodeAuth(serverUrl) {
226
227
  throw new Error('Device code expired. Please try again.');
227
228
  }
228
229
 
230
+ /**
231
+ * Validate an existing auth token against the server.
232
+ * Returns true if token is valid, false if invalid/revoked/unreachable.
233
+ */
234
+ async function validateExistingToken(serverUrl, token) {
235
+ if (!token || !token.startsWith('ailens_dev_')) return false;
236
+ try {
237
+ const parsed = new URL(`${serverUrl}/api/auth/verify`);
238
+ const isHttps = parsed.protocol === 'https:';
239
+ const requestFn = isHttps ? httpsRequest : httpRequest;
240
+ const status = await new Promise((resolve, reject) => {
241
+ const req = requestFn({
242
+ hostname: parsed.hostname,
243
+ port: parsed.port || (isHttps ? 443 : 80),
244
+ path: parsed.pathname,
245
+ method: 'GET',
246
+ headers: { 'X-Auth-Token': token },
247
+ timeout: 10_000,
248
+ }, (res) => {
249
+ res.resume();
250
+ resolve(res.statusCode);
251
+ });
252
+ req.on('error', reject);
253
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
254
+ req.end();
255
+ });
256
+ return status === 200;
257
+ } catch {
258
+ return false;
259
+ }
260
+ }
261
+
229
262
  // =============================================================================
230
263
  // CLI flags for non-interactive mode
231
264
  // =============================================================================
@@ -277,7 +310,7 @@ export default async function init() {
277
310
  if (tools.length === 0) {
278
311
  warn('No supported AI tools detected.');
279
312
  info('Looked for ~/.claude/ and ~/.cursor/ directories.');
280
- info('Install Claude Code or Cursor, then re-run: npx ai-lens init');
313
+ info('Install Claude Code or Cursor, then re-run: npx -y ai-lens init');
281
314
  return;
282
315
  }
283
316
 
@@ -334,6 +367,16 @@ export default async function init() {
334
367
 
335
368
  // Authentication
336
369
  heading('Authentication');
370
+ if (currentConfig.authToken) {
371
+ const valid = await validateExistingToken(serverUrl, currentConfig.authToken);
372
+ if (valid) {
373
+ success(' Already authenticated (token verified)');
374
+ } else {
375
+ warn(' Existing token is invalid or revoked — re-authenticating...');
376
+ currentConfig.authToken = null;
377
+ newConfig.authToken = null;
378
+ }
379
+ }
337
380
  if (!currentConfig.authToken) {
338
381
  try {
339
382
  const result = await deviceCodeAuth(serverUrl);
@@ -348,8 +391,6 @@ export default async function init() {
348
391
  return;
349
392
  }
350
393
  }
351
- } else {
352
- success(' Already authenticated (token present)');
353
394
  }
354
395
  blank();
355
396
 
@@ -594,6 +635,64 @@ export default async function init() {
594
635
  } catch (err) {
595
636
  warn(` Token: could not verify — ${err.message}`);
596
637
  }
638
+
639
+ // 3. E2E: POST a test event
640
+ try {
641
+ const { name: gitName, email: gitEmail } = getGitIdentity();
642
+ const testEvent = [{
643
+ source: 'cli',
644
+ session_id: `e2e-test-${Date.now()}`,
645
+ type: 'E2eTest',
646
+ timestamp: new Date().toISOString(),
647
+ project_path: process.cwd(),
648
+ data: { trigger: 'init' },
649
+ }];
650
+ const headers = {
651
+ 'Content-Type': 'application/json',
652
+ 'X-Auth-Token': finalConfig.authToken,
653
+ };
654
+ if (gitEmail) headers['X-Developer-Git-Email'] = gitEmail;
655
+ if (gitName) headers['X-Developer-Name'] = gitName;
656
+
657
+ const parsed = new URL(`${verifyUrl}/api/events`);
658
+ const isHttps = parsed.protocol === 'https:';
659
+ const requestFn = isHttps ? httpsRequest : httpRequest;
660
+ const data = JSON.stringify(testEvent);
661
+ headers['Content-Length'] = String(Buffer.byteLength(data));
662
+
663
+ const e2eBody = await new Promise((resolve, reject) => {
664
+ const req = requestFn({
665
+ hostname: parsed.hostname,
666
+ port: parsed.port || (isHttps ? 443 : 80),
667
+ path: parsed.pathname,
668
+ method: 'POST',
669
+ headers,
670
+ timeout: 10_000,
671
+ }, (res) => {
672
+ let buf = '';
673
+ res.on('data', (chunk) => { buf += chunk; });
674
+ res.on('end', () => {
675
+ try {
676
+ resolve({ status: res.statusCode, data: JSON.parse(buf) });
677
+ } catch {
678
+ reject(new Error(`Server responded ${res.statusCode}: ${buf}`));
679
+ }
680
+ });
681
+ });
682
+ req.on('error', reject);
683
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
684
+ req.write(data);
685
+ req.end();
686
+ });
687
+
688
+ if (e2eBody.status === 200 && e2eBody.data.received >= 1) {
689
+ success(' E2E: event accepted by server');
690
+ } else {
691
+ error(` E2E: unexpected response (HTTP ${e2eBody.status})`);
692
+ }
693
+ } catch (err) {
694
+ error(` E2E: ${err.message}`);
695
+ }
597
696
  }
598
697
  } else {
599
698
  warn(' No server URL configured — skipping verification');
package/cli/status.js CHANGED
@@ -4,7 +4,7 @@ import { join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
 
6
6
  import { getVersionInfo, readLensConfig, detectInstalledTools, analyzeToolHooks, CAPTURE_PATH, TOOL_CONFIGS } from './hooks.js';
7
- import { DATA_DIR, QUEUE_PATH, LOG_PATH, getGitIdentity } from '../client/config.js';
7
+ import { DATA_DIR, QUEUE_PATH, LOG_PATH, CAPTURE_LOG_PATH, getGitIdentity } from '../client/config.js';
8
8
  import { initLogger, info, success, warn, error, heading, blank } from './logger.js';
9
9
 
10
10
  // ANSI helpers
@@ -286,6 +286,48 @@ function checkSenderLog() {
286
286
  };
287
287
  }
288
288
 
289
+ function checkCaptureLog() {
290
+ if (!existsSync(CAPTURE_LOG_PATH)) {
291
+ return { ok: true, summary: 'no drops logged', detail: 'Capture log does not exist (no events dropped)' };
292
+ }
293
+
294
+ let lines;
295
+ try {
296
+ lines = readFileSync(CAPTURE_LOG_PATH, 'utf-8').split('\n').filter(Boolean);
297
+ } catch (err) {
298
+ return { ok: false, summary: `error reading log: ${err.message}`, detail: `Error: ${err.message}` };
299
+ }
300
+
301
+ // Count entries by category (reason for drops, msg for errors)
302
+ const counts = {};
303
+ let lastTs = null;
304
+ let hasErrors = false;
305
+ for (const line of lines) {
306
+ try {
307
+ const entry = JSON.parse(line);
308
+ const category = entry.reason || entry.msg || 'unknown';
309
+ counts[category] = (counts[category] || 0) + 1;
310
+ lastTs = entry.ts;
311
+ if (entry.msg) hasErrors = true;
312
+ } catch { /* non-JSON line */ }
313
+ }
314
+
315
+ const total = lines.length;
316
+ const breakdown = Object.entries(counts).map(([r, n]) => `${r}: ${n}`).join(', ');
317
+
318
+ let summary = `${total} entries`;
319
+ if (breakdown) summary += ` (${breakdown})`;
320
+ if (lastTs) summary += `, last ${relativeTime(lastTs)}`;
321
+
322
+ const last10 = lines.slice(-10);
323
+
324
+ return {
325
+ ok: !hasErrors,
326
+ summary,
327
+ detail: `Log: ${CAPTURE_LOG_PATH}\nTotal: ${total}\n\nLast 10 entries:\n${last10.join('\n')}`,
328
+ };
329
+ }
330
+
289
331
  async function checkServer(serverUrl) {
290
332
  if (!serverUrl) {
291
333
  return { ok: false, summary: 'no server URL configured', detail: 'Cannot check server: no serverUrl in config' };
@@ -340,6 +382,44 @@ async function checkToken(serverUrl, authToken) {
340
382
  }
341
383
  }
342
384
 
385
+ async function checkE2e(serverUrl, authToken) {
386
+ if (!serverUrl || !authToken) {
387
+ const missing = !serverUrl ? 'server URL' : 'auth token';
388
+ return { ok: false, summary: `no ${missing} configured`, detail: `Cannot run E2E test: missing ${missing}` };
389
+ }
390
+
391
+ const { name, email } = getGitIdentity();
392
+ const testEvent = [{
393
+ source: 'cli',
394
+ session_id: `e2e-test-${Date.now()}`,
395
+ type: 'E2eTest',
396
+ timestamp: new Date().toISOString(),
397
+ project_path: process.cwd(),
398
+ data: { trigger: 'status' },
399
+ }];
400
+
401
+ const url = `${serverUrl}/api/events`;
402
+ try {
403
+ const res = await fetch(url, {
404
+ method: 'POST',
405
+ headers: {
406
+ 'Content-Type': 'application/json',
407
+ 'X-Auth-Token': authToken,
408
+ ...(email && { 'X-Developer-Git-Email': email }),
409
+ ...(name && { 'X-Developer-Name': name }),
410
+ },
411
+ body: JSON.stringify(testEvent),
412
+ });
413
+ const body = await res.json();
414
+ if (res.ok && body.received >= 1) {
415
+ return { ok: true, summary: 'event accepted', detail: `POST ${url} → ${res.status}, received: ${body.received}` };
416
+ }
417
+ return { ok: false, summary: `unexpected response (${res.status})`, detail: `POST ${url} → ${res.status}\nBody: ${JSON.stringify(body)}` };
418
+ } catch (err) {
419
+ return { ok: false, summary: `failed (${err.message})`, detail: `POST ${url}\nError: ${err.message}` };
420
+ }
421
+ }
422
+
343
423
  // ---------------------------------------------------------------------------
344
424
  // Report file generation
345
425
  // ---------------------------------------------------------------------------
@@ -423,6 +503,20 @@ function buildReport(results, timestamp) {
423
503
  }
424
504
  lines.push('');
425
505
 
506
+ // Capture drops log (last 100 lines)
507
+ lines.push(`${'='.repeat(60)}`);
508
+ lines.push(`Capture drops (${CAPTURE_LOG_PATH}):`);
509
+ try {
510
+ const capLines = readFileSync(CAPTURE_LOG_PATH, 'utf-8').split('\n').filter(Boolean);
511
+ lines.push(`Total: ${capLines.length} drops`);
512
+ for (const cl of capLines.slice(-100)) {
513
+ lines.push(cl);
514
+ }
515
+ } catch {
516
+ lines.push('(not found)');
517
+ }
518
+ lines.push('');
519
+
426
520
  return lines.join('\n');
427
521
  }
428
522
 
@@ -498,6 +592,9 @@ export default async function status() {
498
592
  // 8. Sender log
499
593
  printLine('Sender log', checkSenderLog());
500
594
 
595
+ // 8b. Capture drops
596
+ printLine('Capture drops', checkCaptureLog());
597
+
501
598
  // 9. Server connectivity
502
599
  const serverUrl = configResult.serverUrl || readLensConfig().serverUrl;
503
600
  const serverResult = await checkServer(serverUrl);
@@ -508,6 +605,10 @@ export default async function status() {
508
605
  const tokenResult = await checkToken(serverUrl, authToken);
509
606
  printLine('Token', tokenResult);
510
607
 
608
+ // 11. E2E connectivity test
609
+ const e2eResult = await checkE2e(serverUrl, authToken);
610
+ printLine('E2E test', e2eResult);
611
+
511
612
  // Write report file
512
613
  const timestamp = new Date().toISOString();
513
614
  const report = buildReport(results, timestamp);
package/client/capture.js CHANGED
@@ -17,6 +17,8 @@ import {
17
17
  QUEUE_PATH,
18
18
  SESSION_PATHS_PATH,
19
19
  LAST_EVENTS_PATH,
20
+ CAPTURE_LOG_PATH,
21
+ captureLog,
20
22
  getServerUrl,
21
23
  getGitIdentity,
22
24
  getGitMetadata,
@@ -31,6 +33,13 @@ try {
31
33
 
32
34
  const __dirname = dirname(fileURLToPath(import.meta.url));
33
35
 
36
+ function logDrop(reason, meta = {}) {
37
+ try {
38
+ const entry = { ts: new Date().toISOString(), reason, ...meta };
39
+ appendFileSync(CAPTURE_LOG_PATH, JSON.stringify(entry) + '\n');
40
+ } catch { /* best-effort */ }
41
+ }
42
+
34
43
  // =============================================================================
35
44
  // Truncation (reused from ai-session-lens prompts.js approach)
36
45
  // =============================================================================
@@ -117,7 +126,9 @@ function cacheSessionPath(sessionId, projectPath) {
117
126
  const paths = loadSessionPaths();
118
127
  if (paths[sessionId] !== projectPath) {
119
128
  paths[sessionId] = projectPath;
120
- saveSessionPaths(paths);
129
+ try {
130
+ saveSessionPaths(paths);
131
+ } catch { /* cache write failed — event proceeds without cached path */ }
121
132
  }
122
133
  }
123
134
 
@@ -159,7 +170,9 @@ export function isDuplicateEvent(sessionId, type) {
159
170
  const dominated = DEDUP_TYPES.has(type) && prev === type;
160
171
  if (prev !== type) {
161
172
  cache[sessionId] = type;
162
- saveLastEvents(cache);
173
+ try {
174
+ saveLastEvents(cache);
175
+ } catch { /* dedup cache write failed — proceed with stale state */ }
163
176
  }
164
177
  return dominated;
165
178
  }
@@ -322,7 +335,12 @@ function normalizeCursor(event) {
322
335
  const hookName = event.hook_event_name;
323
336
  const type = CURSOR_TYPE_MAP[hookName] || hookName;
324
337
  const timestamp = new Date().toISOString();
325
- const projectPath = Array.isArray(event.workspace_roots) ? event.workspace_roots[0] : null;
338
+ let projectPath = Array.isArray(event.workspace_roots) ? event.workspace_roots[0] : null;
339
+ if (projectPath) {
340
+ cacheSessionPath(sessionId, projectPath);
341
+ } else {
342
+ projectPath = getCachedSessionPath(sessionId);
343
+ }
326
344
 
327
345
  let data = {};
328
346
  switch (hookName) {
@@ -494,18 +512,20 @@ async function main() {
494
512
 
495
513
  const unified = normalizeEvent(event);
496
514
  if (!unified || !unified.session_id) {
497
- // Unknown source or no session_id — drop
515
+ logDrop('normalize_failed', { hook: event.hook_event_name });
498
516
  process.exit(0);
499
517
  }
500
518
 
501
519
  // Deduplicate consecutive identical event types (e.g. repeated Stop from idle sessions)
502
520
  if (isDuplicateEvent(unified.session_id, unified.type)) {
521
+ logDrop('duplicate', { type: unified.type, session_id: unified.session_id });
503
522
  process.exit(0);
504
523
  }
505
524
 
506
525
  // Filter by monitored projects (if configured)
507
526
  const monitored = getMonitoredProjects();
508
527
  if (monitored && !monitored.some(p => unified.project_path === p || unified.project_path?.startsWith(p + '/'))) {
528
+ logDrop('project_filter', { type: unified.type, source: unified.source, session_id: unified.session_id, project_path: unified.project_path, monitored });
509
529
  process.exit(0);
510
530
  }
511
531
 
@@ -513,6 +533,7 @@ async function main() {
513
533
  const identity = getGitIdentity();
514
534
  const email = identity.email || event.user_email || null;
515
535
  if (!email) {
536
+ logDrop('no_email', { type: unified.type, session_id: unified.session_id });
516
537
  process.exit(0);
517
538
  }
518
539
  unified.developer_email = email;
@@ -525,14 +546,27 @@ async function main() {
525
546
  unified.git_commit = gitMeta.git_commit;
526
547
 
527
548
  // Append to queue
528
- appendToQueue(unified);
549
+ try {
550
+ appendToQueue(unified);
551
+ } catch (err) {
552
+ captureLog({ msg: 'queue-write-failed', error: err.message, type: unified.type, session_id: unified.session_id });
553
+ process.exit(1);
554
+ }
529
555
 
530
556
  // Always try to spawn sender — atomic rename in sender handles dedup
531
- trySpawnSender();
557
+ try {
558
+ trySpawnSender();
559
+ } catch (err) {
560
+ captureLog({ msg: 'sender-spawn-failed', error: err.message });
561
+ // event is queued — sender will be spawned on next capture
562
+ }
532
563
  }
533
564
 
534
565
  // Only run main when executed directly (not when imported for testing)
535
566
  const isDirectRun = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
536
567
  if (isDirectRun) {
537
- main().catch(() => process.exit(0));
568
+ main().catch((err) => {
569
+ try { captureLog({ msg: 'capture-error', error: err.message }); } catch {}
570
+ process.exit(1);
571
+ });
538
572
  }
package/client/config.js CHANGED
@@ -11,12 +11,20 @@ export const SESSION_PATHS_PATH = join(DATA_DIR, 'session-paths.json');
11
11
  export const GIT_REMOTES_PATH = join(DATA_DIR, 'git-remotes.json');
12
12
  export const LAST_EVENTS_PATH = join(DATA_DIR, 'last-events.json');
13
13
  export const LOG_PATH = join(DATA_DIR, 'sender.log');
14
+ export const CAPTURE_LOG_PATH = join(DATA_DIR, 'capture.log');
14
15
 
15
16
  export function log(fields) {
16
17
  const entry = { ts: new Date().toISOString(), ...fields };
17
18
  appendFileSync(LOG_PATH, JSON.stringify(entry) + '\n');
18
19
  }
19
20
 
21
+ export function captureLog(fields) {
22
+ const entry = { ts: new Date().toISOString(), ...fields };
23
+ try {
24
+ appendFileSync(CAPTURE_LOG_PATH, JSON.stringify(entry) + '\n');
25
+ } catch { /* last resort — disk may be full */ }
26
+ }
27
+
20
28
  let _configCache;
21
29
  function loadConfig() {
22
30
  if (_configCache !== undefined) return _configCache;
@@ -52,10 +60,8 @@ export function getMonitoredProjects() {
52
60
  .map(p => p.endsWith('/') ? p.slice(0, -1) : p);
53
61
  }
54
62
 
55
- const DEFAULT_AUTH_TOKEN = 'collector:secret-collector-token-2026-ai-lens';
56
-
57
63
  export function getAuthToken() {
58
- return process.env.AI_LENS_AUTH_TOKEN || loadConfig().authToken || DEFAULT_AUTH_TOKEN;
64
+ return process.env.AI_LENS_AUTH_TOKEN || loadConfig().authToken || null;
59
65
  }
60
66
 
61
67
  export function getGitIdentity() {
package/client/sender.js CHANGED
@@ -84,24 +84,43 @@ export function buildRollbackContent(unsentContent, existingContent) {
84
84
  * renameSync is atomic on POSIX — acts as a mutex.
85
85
  * Returns { events, sendingPath } or null if nothing to send.
86
86
  */
87
- function acquireQueue() {
87
+ /**
88
+ * @param {string} [queuePath] - Override queue path (for testing); defaults to QUEUE_PATH
89
+ * @param {string} [sendingPath] - Override sending path (for testing); defaults to SENDING_PATH
90
+ */
91
+ export function acquireQueue(queuePath = QUEUE_PATH, sendingPath = SENDING_PATH) {
92
+ // Recover orphaned sending file from a previously crashed sender
88
93
  try {
89
- renameSync(QUEUE_PATH, SENDING_PATH);
94
+ const orphaned = readFileSync(sendingPath, 'utf-8');
95
+ if (orphaned.trim()) {
96
+ let existing = '';
97
+ try { existing = readFileSync(queuePath, 'utf-8'); } catch { /* no queue yet */ }
98
+ writeFileSync(queuePath, orphaned + existing);
99
+ log({ msg: 'orphan-recovered', bytes: Buffer.byteLength(orphaned) });
100
+ }
101
+ unlinkSync(sendingPath);
102
+ } catch (err) {
103
+ if (err.code !== 'ENOENT') throw err;
104
+ // No orphan — normal path
105
+ }
106
+
107
+ try {
108
+ renameSync(queuePath, sendingPath);
90
109
  } catch (err) {
91
110
  if (err.code === 'ENOENT') return null; // no queue or another sender got it
92
111
  throw err;
93
112
  }
94
113
 
95
- const content = readFileSync(SENDING_PATH, 'utf-8');
114
+ const content = readFileSync(sendingPath, 'utf-8');
96
115
  const { events, dropped, overflow } = parseQueueContent(content);
97
116
  if (dropped > 0) log({ msg: 'queue-corruption', dropped });
98
117
  if (overflow > 0) log({ msg: 'queue-overflow', dropped: overflow, kept: MAX_QUEUE_SIZE });
99
118
  if (events.length === 0) {
100
- unlinkSync(SENDING_PATH);
119
+ unlinkSync(sendingPath);
101
120
  return null;
102
121
  }
103
122
 
104
- return { events, sendingPath: SENDING_PATH };
123
+ return { events, sendingPath };
105
124
  }
106
125
 
107
126
  /**
@@ -289,6 +308,9 @@ async function main() {
289
308
  const unsentEvents = events.filter(e => !sentEventIds.has(e.event_id));
290
309
  partialRollback(sendingPath, unsentEvents, events.length);
291
310
  log({ msg: 'failed', error: err.message, sent: events.length - unsentEvents.length, unsent: unsentEvents.length, server: serverUrl });
311
+ if (err.message.includes('401')) {
312
+ log({ msg: 'auth-failed', error: 'Token invalid or revoked. Run: npx -y ai-lens init' });
313
+ }
292
314
  }
293
315
  }
294
316
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {