ai-lens 0.5.0 → 0.6.2

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
- 968235e
1
+ 3e7557f
package/bin/ai-lens.js CHANGED
@@ -13,10 +13,6 @@ switch (command) {
13
13
  await remove();
14
14
  break;
15
15
  }
16
- case 'mcp': {
17
- await import('../mcp-server/index.js');
18
- break;
19
- }
20
16
  case 'version':
21
17
  case '--version':
22
18
  case '-v': {
@@ -35,8 +31,12 @@ switch (command) {
35
31
  console.log('');
36
32
  console.log('Commands:');
37
33
  console.log(' init Configure AI tool hooks for event capture');
34
+ console.log(' --server URL Server URL (default: saved or http://localhost:3000)');
35
+ console.log(' --yes, -y Non-interactive: accept all defaults, no prompts');
36
+ console.log(' --projects LIST Comma-separated project paths to track');
37
+ console.log(' --no-mcp Skip MCP server registration');
38
+ console.log(' --mcp-scope S MCP scope: user, local, or project (default: user)');
38
39
  console.log(' remove Remove AI Lens hooks and client files');
39
- console.log(' mcp Start the MCP server (stdio transport)');
40
40
  console.log(' version Show package version and commit hash');
41
41
  process.exit(command ? 1 : 0);
42
42
  }
package/cli/hooks.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync, copyFileSync, renameSync, mkdirSync, rmSync } from 'node:fs';
1
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, renameSync, mkdirSync, rmSync, unlinkSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { fileURLToPath } from 'node:url';
@@ -150,6 +150,8 @@ export const TOOL_CONFIGS = [
150
150
  hookDefs: CLAUDE_CODE_HOOKS,
151
151
  topLevelFields: {},
152
152
  sharedConfig: true,
153
+ // Older init versions wrote hooks here — clean up on init/remove
154
+ legacyConfigPaths: [join(homedir(), '.claude', 'hooks.json')],
153
155
  },
154
156
  {
155
157
  name: 'Cursor',
@@ -350,6 +352,123 @@ export function writeHooksConfig(tool, config) {
350
352
  // Describe what will happen (for plan display)
351
353
  // ---------------------------------------------------------------------------
352
354
 
355
+ /**
356
+ * Clean up AI Lens hooks from legacy config paths (e.g. ~/.claude/hooks.json).
357
+ * Returns array of { path, action } describing what was done.
358
+ */
359
+ export function cleanupLegacyHooks(tool) {
360
+ const results = [];
361
+ if (!Array.isArray(tool.legacyConfigPaths)) return results;
362
+
363
+ for (const legacyPath of tool.legacyConfigPaths) {
364
+ if (legacyPath === tool.configPath) continue;
365
+ if (!existsSync(legacyPath)) continue;
366
+
367
+ let raw;
368
+ try {
369
+ raw = readFileSync(legacyPath, 'utf-8');
370
+ } catch {
371
+ continue;
372
+ }
373
+
374
+ let config;
375
+ try {
376
+ config = JSON.parse(raw);
377
+ } catch {
378
+ continue;
379
+ }
380
+
381
+ // Check if any AI Lens hooks exist in this file
382
+ const hooks = config.hooks;
383
+ if (!hooks || typeof hooks !== 'object') continue;
384
+
385
+ let hasAiLens = false;
386
+ for (const entries of Object.values(hooks)) {
387
+ if (Array.isArray(entries) && entries.some(e => isAiLensHook(e))) {
388
+ hasAiLens = true;
389
+ break;
390
+ }
391
+ }
392
+ if (!hasAiLens) continue;
393
+
394
+ // Strip AI Lens hooks
395
+ const stripped = buildStrippedConfig(tool, config);
396
+ if (stripped) {
397
+ writeFileSync(legacyPath, JSON.stringify(stripped, null, 2) + '\n');
398
+ results.push({ path: legacyPath, action: 'cleaned' });
399
+ } else {
400
+ unlinkSync(legacyPath);
401
+ results.push({ path: legacyPath, action: 'deleted' });
402
+ }
403
+ }
404
+
405
+ return results;
406
+ }
407
+
408
+ /**
409
+ * Delete .mcp.json in cwd if it was left with empty mcpServers by `claude mcp remove`.
410
+ */
411
+ export function cleanupEmptyMcpJson() {
412
+ const mcpJsonPath = join(process.cwd(), '.mcp.json');
413
+ try {
414
+ const content = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
415
+ if (content.mcpServers
416
+ && Object.keys(content.mcpServers).length === 0
417
+ && Object.keys(content).length === 1) {
418
+ unlinkSync(mcpJsonPath);
419
+ }
420
+ } catch {
421
+ // file doesn't exist or isn't valid JSON — nothing to clean
422
+ }
423
+ }
424
+
425
+ // ---------------------------------------------------------------------------
426
+ // Cursor MCP helpers (no CLI — direct JSON editing)
427
+ // ---------------------------------------------------------------------------
428
+
429
+ const CURSOR_MCP_GLOBAL = join(homedir(), '.cursor', 'mcp.json');
430
+
431
+ function cursorMcpEntry(mcpUrl) {
432
+ return { url: mcpUrl };
433
+ }
434
+
435
+ function readJsonSafe(path) {
436
+ try {
437
+ return JSON.parse(readFileSync(path, 'utf-8'));
438
+ } catch {
439
+ return null;
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Add or update ai-lens MCP server in Cursor's mcp.json.
445
+ */
446
+ export function addCursorMcp(mcpUrl) {
447
+ const config = readJsonSafe(CURSOR_MCP_GLOBAL) || { mcpServers: {} };
448
+ if (!config.mcpServers) config.mcpServers = {};
449
+ config.mcpServers['ai-lens'] = cursorMcpEntry(mcpUrl);
450
+ mkdirSync(dirname(CURSOR_MCP_GLOBAL), { recursive: true });
451
+ writeFileSync(CURSOR_MCP_GLOBAL, JSON.stringify(config, null, 2) + '\n');
452
+ }
453
+
454
+ /**
455
+ * Remove ai-lens MCP server from Cursor's mcp.json.
456
+ * Also checks .cursor/mcp.json in cwd (project scope).
457
+ */
458
+ export function removeCursorMcp() {
459
+ const paths = [CURSOR_MCP_GLOBAL, join(process.cwd(), '.cursor', 'mcp.json')];
460
+ for (const p of paths) {
461
+ const config = readJsonSafe(p);
462
+ if (!config?.mcpServers?.['ai-lens']) continue;
463
+ delete config.mcpServers['ai-lens'];
464
+ if (Object.keys(config.mcpServers).length === 0 && Object.keys(config).length === 1) {
465
+ unlinkSync(p);
466
+ } else {
467
+ writeFileSync(p, JSON.stringify(config, null, 2) + '\n');
468
+ }
469
+ }
470
+ }
471
+
353
472
  export function describePlan(tool, analysis) {
354
473
  const hookNames = Object.keys(tool.hookDefs);
355
474
 
package/cli/init.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  CAPTURE_PATH, detectInstalledTools,
14
14
  analyzeToolHooks, buildMergedConfig, writeHooksConfig, describePlan,
15
15
  installClientFiles, readLensConfig, saveLensConfig, getVersionInfo,
16
+ cleanupLegacyHooks, cleanupEmptyMcpJson, addCursorMcp, removeCursorMcp,
16
17
  } from './hooks.js';
17
18
 
18
19
  function ask(question) {
@@ -152,14 +153,13 @@ async function deviceCodeAuth(serverUrl) {
152
153
  throw new Error('Auth0 device code flow not configured on server (missing AUTH0_CLI_CLIENT_ID)');
153
154
  }
154
155
 
155
- const { domain, cliClientId, audience } = config;
156
+ const { domain, cliClientId } = config;
156
157
 
157
158
  // 2. Request device code
158
159
  const codeParams = {
159
160
  client_id: cliClientId,
160
161
  scope: 'openid profile email',
161
162
  };
162
- if (audience) codeParams.audience = audience;
163
163
 
164
164
  const codeResp = await postForm(`https://${domain}/oauth/device/code`, codeParams);
165
165
  if (codeResp.status !== 200) {
@@ -226,7 +226,42 @@ async function deviceCodeAuth(serverUrl) {
226
226
  throw new Error('Device code expired. Please try again.');
227
227
  }
228
228
 
229
+ // =============================================================================
230
+ // CLI flags for non-interactive mode
231
+ // =============================================================================
232
+
233
+ function getInitArgs() {
234
+ const args = process.argv.slice(3); // skip "node", script, "init"
235
+ const flags = {};
236
+
237
+ for (let i = 0; i < args.length; i++) {
238
+ switch (args[i]) {
239
+ case '--server':
240
+ flags.server = args[++i];
241
+ break;
242
+ case '--projects':
243
+ flags.projects = args[++i];
244
+ break;
245
+ case '--yes':
246
+ case '-y':
247
+ flags.yes = true;
248
+ break;
249
+ case '--no-mcp':
250
+ flags.noMcp = true;
251
+ break;
252
+ case '--mcp-scope':
253
+ flags.mcpScope = args[++i];
254
+ break;
255
+ }
256
+ }
257
+
258
+ return flags;
259
+ }
260
+
229
261
  export default async function init() {
262
+ const flags = getInitArgs();
263
+ const auto = flags.yes || false;
264
+
230
265
  const { version, commit } = getVersionInfo();
231
266
  initLogger(`v${version} (${commit})`);
232
267
 
@@ -257,19 +292,33 @@ export default async function init() {
257
292
 
258
293
  // Server URL
259
294
  const currentServer = currentConfig.serverUrl || 'http://localhost:3000';
260
- const serverInput = await ask(
261
- `Server URL (Enter = ${currentServer}): `,
262
- );
263
- const serverUrl = serverInput || currentServer;
295
+ let serverUrl;
296
+ if (flags.server) {
297
+ serverUrl = flags.server.replace(/\/+$/, '');
298
+ } else if (auto) {
299
+ serverUrl = currentServer;
300
+ } else {
301
+ const serverInput = await ask(
302
+ `Server URL (Enter = ${currentServer}): `,
303
+ );
304
+ serverUrl = (serverInput || currentServer).replace(/\/+$/, '');
305
+ }
264
306
  info(` Server: ${serverUrl}`);
265
307
 
266
308
  // Project filter
267
309
  const currentProjects = currentConfig.projects || null;
268
- const projectsDefault = currentProjects || 'all';
269
- const projectsInput = await ask(
270
- `Projects to track (comma-separated, ~ supported, Enter = ${projectsDefault}): `,
271
- );
272
- const projects = projectsInput || currentProjects;
310
+ let projects;
311
+ if (flags.projects) {
312
+ projects = flags.projects;
313
+ } else if (auto) {
314
+ projects = currentProjects;
315
+ } else {
316
+ const projectsDefault = currentProjects || 'all';
317
+ const projectsInput = await ask(
318
+ `Projects to track (comma-separated, ~ supported, Enter = ${projectsDefault}): `,
319
+ );
320
+ projects = projectsInput || currentProjects;
321
+ }
273
322
  if (projects) {
274
323
  info(` Tracking: ${projects}`);
275
324
  } else {
@@ -292,8 +341,12 @@ export default async function init() {
292
341
  saveLensConfig(newConfig);
293
342
  success(` Authenticated as ${result.name} (${result.email})`);
294
343
  } catch (err) {
295
- error(` Authentication failed: ${err.message}`);
296
- return;
344
+ if (err.message.includes('not configured')) {
345
+ warn(` Auth not configured on server — personal mode (events sent via git identity)`);
346
+ } else {
347
+ error(` Authentication failed: ${err.message}`);
348
+ return;
349
+ }
297
350
  }
298
351
  } else {
299
352
  success(' Already authenticated (token present)');
@@ -329,6 +382,13 @@ export default async function init() {
329
382
  // Filter to tools that need changes
330
383
  const pending = analyses.filter(a => a.analysis.status !== 'current');
331
384
 
385
+ // Clean up legacy hook locations (always, even if current hooks are up-to-date)
386
+ for (const { tool } of analyses) {
387
+ for (const lr of cleanupLegacyHooks(tool)) {
388
+ success(` ${tool.name}: ${lr.action} legacy hooks in ${lr.path}`);
389
+ }
390
+ }
391
+
332
392
  if (pending.length === 0) {
333
393
  success('Everything is up-to-date. Nothing to do.');
334
394
  } else {
@@ -343,10 +403,12 @@ export default async function init() {
343
403
  blank();
344
404
 
345
405
  // Confirm
346
- const answer = await ask('Proceed? [Y/n] ');
347
- if (answer && answer.toLowerCase() !== 'y') {
348
- info('Aborted.');
349
- return;
406
+ if (!auto) {
407
+ const answer = await ask('Proceed? [Y/n] ');
408
+ if (answer && answer.toLowerCase() !== 'y') {
409
+ info('Aborted.');
410
+ return;
411
+ }
350
412
  }
351
413
  blank();
352
414
 
@@ -391,35 +453,78 @@ export default async function init() {
391
453
  blank();
392
454
  }
393
455
 
394
- // MCP setup (only if Claude Code is installed)
456
+ // MCP setup (HTTP transport auth via OAuth in browser, no token needed)
457
+ const mcpUrl = `${serverUrl}/mcp`;
458
+ const setupMcp = !flags.noMcp;
459
+
460
+ // Claude Code MCP
395
461
  const claudeDir = join(homedir(), '.claude');
396
462
  const hasClaudeDir = existsSync(claudeDir);
397
463
  let hasClaudeCli = false;
398
- try {
399
- execSync('which claude', { stdio: 'ignore' });
400
- hasClaudeCli = true;
401
- } catch {}
464
+ try { execSync('which claude', { stdio: 'ignore' }); hasClaudeCli = true; } catch {}
402
465
 
403
466
  if (hasClaudeDir && hasClaudeCli) {
404
- heading('MCP Server');
405
- const mcpAnswer = await ask('Set up AI Lens MCP server in Claude Code? [Y/n] ');
406
- if (!mcpAnswer || mcpAnswer.toLowerCase() === 'y') {
407
- const mcpToken = newConfig.authToken;
408
- if (!mcpToken) {
409
- warn(' No auth token available authenticate first.');
467
+ heading('MCP Server — Claude Code');
468
+ let doSetup;
469
+ if (auto) {
470
+ doSetup = setupMcp;
471
+ } else {
472
+ const mcpAnswer = await ask('Set up AI Lens MCP server in Claude Code? [Y/n] ');
473
+ doSetup = !mcpAnswer || mcpAnswer.toLowerCase() === 'y';
474
+ }
475
+
476
+ if (doSetup) {
477
+ let scope;
478
+ if (flags.mcpScope && ['local', 'project', 'user'].includes(flags.mcpScope)) {
479
+ scope = flags.mcpScope;
480
+ } else if (auto) {
481
+ scope = 'user';
410
482
  } else {
411
- try {
412
- execSync(
413
- `claude mcp add ai-lens -e AI_LENS_SERVER_URL=${serverUrl} -e AI_LENS_AUTH_TOKEN=${mcpToken} -- npx -y ai-lens mcp`,
414
- { stdio: 'inherit' },
415
- );
416
- success(' MCP server registered in Claude Code');
417
- } catch (err) {
418
- error(` Failed to register MCP server: ${err.message}`);
419
- }
483
+ const scopeInput = await ask(' Scope — user, local, or project? (Enter = user): ');
484
+ scope = ['local', 'project', 'user'].includes(scopeInput) ? scopeInput : 'user';
485
+ }
486
+ try {
487
+ // Remove old stdio-based MCP from all scopes, then add HTTP-based
488
+ try { execSync('claude mcp remove ai-lens -s local', { stdio: 'ignore' }); } catch {}
489
+ try { execSync('claude mcp remove ai-lens -s project', { stdio: 'ignore' }); } catch {}
490
+ try { execSync('claude mcp remove ai-lens -s user', { stdio: 'ignore' }); } catch {}
491
+ cleanupEmptyMcpJson();
492
+ execSync(
493
+ `claude mcp add --transport http ai-lens -s ${scope} ${mcpUrl}`,
494
+ { stdio: 'inherit' },
495
+ );
496
+ success(` MCP server registered in Claude Code (${scope})`);
497
+ } catch (err) {
498
+ error(` Failed to register MCP server: ${err.message}`);
499
+ }
500
+ } else {
501
+ info(' Skipped');
502
+ }
503
+ blank();
504
+ }
505
+
506
+ // Cursor MCP
507
+ const cursorDir = join(homedir(), '.cursor');
508
+ if (existsSync(cursorDir)) {
509
+ heading('MCP Server — Cursor');
510
+ let doSetup;
511
+ if (auto) {
512
+ doSetup = setupMcp;
513
+ } else {
514
+ const cursorMcpAnswer = await ask('Set up AI Lens MCP server in Cursor? [Y/n] ');
515
+ doSetup = !cursorMcpAnswer || cursorMcpAnswer.toLowerCase() === 'y';
516
+ }
517
+
518
+ if (doSetup) {
519
+ try {
520
+ removeCursorMcp();
521
+ addCursorMcp(mcpUrl);
522
+ success(' MCP server registered in Cursor (~/.cursor/mcp.json)');
523
+ } catch (err) {
524
+ error(` Failed to register MCP server: ${err.message}`);
420
525
  }
421
526
  } else {
422
- info(' Skipped MCP setup');
527
+ info(' Skipped');
423
528
  }
424
529
  blank();
425
530
  }
package/cli/remove.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { createInterface } from 'node:readline';
2
- import { unlinkSync } from 'node:fs';
2
+ import { unlinkSync, existsSync } from 'node:fs';
3
+ import { execSync } from 'node:child_process';
4
+ import { join } from 'node:path';
5
+ import { homedir } from 'node:os';
3
6
  import {
4
7
  initLogger, info, success, warn, error,
5
8
  heading, detail, blank, getLogPath,
@@ -7,6 +10,7 @@ import {
7
10
  import {
8
11
  detectInstalledTools, analyzeToolHooks,
9
12
  buildStrippedConfig, writeHooksConfig, removeClientFiles, getVersionInfo,
13
+ cleanupLegacyHooks, cleanupEmptyMcpJson, removeCursorMcp,
10
14
  } from './hooks.js';
11
15
 
12
16
  function ask(question) {
@@ -88,6 +92,46 @@ export default async function remove() {
88
92
  }
89
93
  blank();
90
94
 
95
+ // Clean up legacy hook locations
96
+ for (const tool of tools) {
97
+ for (const lr of cleanupLegacyHooks(tool)) {
98
+ success(` ${tool.name}: ${lr.action} legacy hooks in ${lr.path}`);
99
+ }
100
+ }
101
+ blank();
102
+
103
+ // Remove MCP servers
104
+ heading('Removing MCP servers...');
105
+
106
+ // Claude Code
107
+ const claudeDir = join(homedir(), '.claude');
108
+ let hasClaudeCli = false;
109
+ try { execSync('which claude', { stdio: 'ignore' }); hasClaudeCli = true; } catch {}
110
+
111
+ if (existsSync(claudeDir) && hasClaudeCli) {
112
+ try {
113
+ try { execSync('claude mcp remove ai-lens -s local', { stdio: 'ignore' }); } catch {}
114
+ try { execSync('claude mcp remove ai-lens -s project', { stdio: 'ignore' }); } catch {}
115
+ try { execSync('claude mcp remove ai-lens -s user', { stdio: 'ignore' }); } catch {}
116
+ cleanupEmptyMcpJson();
117
+ success(' Claude Code: MCP server removed');
118
+ } catch (err) {
119
+ error(` Claude Code: failed — ${err.message}`);
120
+ }
121
+ }
122
+
123
+ // Cursor
124
+ const cursorDir = join(homedir(), '.cursor');
125
+ if (existsSync(cursorDir)) {
126
+ try {
127
+ removeCursorMcp();
128
+ success(' Cursor: MCP server removed');
129
+ } catch (err) {
130
+ error(` Cursor: failed — ${err.message}`);
131
+ }
132
+ }
133
+ blank();
134
+
91
135
  // Remove client files
92
136
  heading('Removing client files...');
93
137
  try {
package/client/capture.js CHANGED
@@ -16,8 +16,10 @@ import {
16
16
  ensureDataDir,
17
17
  QUEUE_PATH,
18
18
  SESSION_PATHS_PATH,
19
+ LAST_EVENTS_PATH,
19
20
  getServerUrl,
20
21
  getGitIdentity,
22
+ getGitMetadata,
21
23
  getMonitoredProjects,
22
24
  } from './config.js';
23
25
  // Soft import — redact.js may not exist on older client installs
@@ -124,6 +126,44 @@ function getCachedSessionPath(sessionId) {
124
126
  return paths[sessionId] || null;
125
127
  }
126
128
 
129
+ // =============================================================================
130
+ // Deduplication: drop consecutive identical event types per session
131
+ // =============================================================================
132
+
133
+ // Event types that should be deduplicated when repeated consecutively
134
+ const DEDUP_TYPES = new Set(['Stop', 'SessionEnd']);
135
+
136
+ function loadLastEvents() {
137
+ if (!existsSync(LAST_EVENTS_PATH)) return {};
138
+ try {
139
+ return JSON.parse(readFileSync(LAST_EVENTS_PATH, 'utf-8'));
140
+ } catch {
141
+ return {};
142
+ }
143
+ }
144
+
145
+ function saveLastEvents(cache) {
146
+ ensureDataDir();
147
+ const tmpPath = LAST_EVENTS_PATH + '.tmp.' + process.pid;
148
+ writeFileSync(tmpPath, JSON.stringify(cache));
149
+ renameSync(tmpPath, LAST_EVENTS_PATH);
150
+ }
151
+
152
+ /**
153
+ * Returns true if this event is a duplicate that should be dropped.
154
+ * Updates the cache with the current event type.
155
+ */
156
+ export function isDuplicateEvent(sessionId, type) {
157
+ const cache = loadLastEvents();
158
+ const prev = cache[sessionId];
159
+ const dominated = DEDUP_TYPES.has(type) && prev === type;
160
+ if (prev !== type) {
161
+ cache[sessionId] = type;
162
+ saveLastEvents(cache);
163
+ }
164
+ return dominated;
165
+ }
166
+
127
167
  // =============================================================================
128
168
  // Normalization: Claude Code
129
169
  // =============================================================================
@@ -458,6 +498,11 @@ async function main() {
458
498
  process.exit(0);
459
499
  }
460
500
 
501
+ // Deduplicate consecutive identical event types (e.g. repeated Stop from idle sessions)
502
+ if (isDuplicateEvent(unified.session_id, unified.type)) {
503
+ process.exit(0);
504
+ }
505
+
461
506
  // Filter by monitored projects (if configured)
462
507
  const monitored = getMonitoredProjects();
463
508
  if (monitored && !monitored.some(p => unified.project_path === p || unified.project_path?.startsWith(p + '/'))) {
@@ -473,6 +518,12 @@ async function main() {
473
518
  unified.developer_email = email;
474
519
  unified.developer_name = identity.name || event.user_name || email;
475
520
 
521
+ // Attach git metadata (remote, branch, commit)
522
+ const gitMeta = getGitMetadata(unified.project_path);
523
+ unified.git_remote = gitMeta.git_remote;
524
+ unified.git_branch = gitMeta.git_branch;
525
+ unified.git_commit = gitMeta.git_commit;
526
+
476
527
  // Append to queue
477
528
  appendToQueue(unified);
478
529
 
package/client/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdirSync, appendFileSync } from 'node:fs';
1
+ import { mkdirSync, appendFileSync, readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { execSync } from 'node:child_process';
@@ -7,6 +7,8 @@ export const DATA_DIR = join(homedir(), '.ai-lens');
7
7
  export const QUEUE_PATH = join(DATA_DIR, 'queue.jsonl');
8
8
  export const SENDING_PATH = join(DATA_DIR, 'queue.sending.jsonl');
9
9
  export const SESSION_PATHS_PATH = join(DATA_DIR, 'session-paths.json');
10
+ export const GIT_REMOTES_PATH = join(DATA_DIR, 'git-remotes.json');
11
+ export const LAST_EVENTS_PATH = join(DATA_DIR, 'last-events.json');
10
12
  export const LOG_PATH = join(DATA_DIR, 'sender.log');
11
13
 
12
14
  export function log(fields) {
@@ -57,3 +59,56 @@ export function getGitIdentity() {
57
59
 
58
60
  return { email, name };
59
61
  }
62
+
63
+ // =============================================================================
64
+ // Git Metadata (remote, branch, commit per event)
65
+ // =============================================================================
66
+
67
+ function loadGitRemotes() {
68
+ if (!existsSync(GIT_REMOTES_PATH)) return {};
69
+ try {
70
+ return JSON.parse(readFileSync(GIT_REMOTES_PATH, 'utf-8'));
71
+ } catch {
72
+ return {};
73
+ }
74
+ }
75
+
76
+ function saveGitRemotes(remotes) {
77
+ ensureDataDir();
78
+ const tmpPath = GIT_REMOTES_PATH + '.tmp.' + process.pid;
79
+ writeFileSync(tmpPath, JSON.stringify(remotes));
80
+ renameSync(tmpPath, GIT_REMOTES_PATH);
81
+ }
82
+
83
+ function getCachedRemote(projectPath) {
84
+ const remotes = loadGitRemotes();
85
+ return remotes[projectPath]; // undefined = not cached, null = no remote
86
+ }
87
+
88
+ function cacheRemote(projectPath, remote) {
89
+ const remotes = loadGitRemotes();
90
+ if (remotes[projectPath] !== remote) {
91
+ remotes[projectPath] = remote;
92
+ saveGitRemotes(remotes);
93
+ }
94
+ }
95
+
96
+ export function getGitMetadata(projectPath) {
97
+ if (!projectPath) return { git_remote: null, git_branch: null, git_commit: null };
98
+ const opts = { encoding: 'utf-8', timeout: 3000, cwd: projectPath };
99
+
100
+ // git_remote: cached per project_path (stable, ~0ms after first call)
101
+ let git_remote = getCachedRemote(projectPath);
102
+ if (git_remote === undefined) {
103
+ try { git_remote = execSync('git remote get-url origin', opts).trim() || null; }
104
+ catch { git_remote = null; }
105
+ cacheRemote(projectPath, git_remote);
106
+ }
107
+
108
+ // git_branch + git_commit: every event (~5ms each)
109
+ let git_branch = null, git_commit = null;
110
+ try { git_branch = execSync('git rev-parse --abbrev-ref HEAD', opts).trim() || null; } catch {}
111
+ try { git_commit = execSync('git rev-parse --short HEAD', opts).trim() || null; } catch {}
112
+
113
+ return { git_remote, git_branch, git_commit };
114
+ }
package/client/sender.js CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  } from './config.js';
25
25
 
26
26
  export const MAX_QUEUE_SIZE = 10_000;
27
+ export const MAX_CHUNK_BYTES = 4 * 1024 * 1024; // 4 MB per POST (Express limit is 5 MB)
27
28
 
28
29
  /**
29
30
  * Parse queue file content into events array.
@@ -148,6 +149,39 @@ export function partialRollback(sendingPath, unsentEvents, totalCount, queuePath
148
149
  }
149
150
  }
150
151
 
152
+ /**
153
+ * Split an array of events into chunks that fit within MAX_CHUNK_BYTES.
154
+ * Each chunk is JSON-serialized as an array; we ensure the serialized
155
+ * size stays under the limit.
156
+ */
157
+ export function chunkEvents(events, maxBytes = MAX_CHUNK_BYTES) {
158
+ const chunks = [];
159
+ let chunk = [];
160
+ let chunkSize = 2; // opening '[' + closing ']'
161
+
162
+ for (const evt of events) {
163
+ const evtJson = JSON.stringify(evt);
164
+ const evtBytes = Buffer.byteLength(evtJson);
165
+ // comma separator between elements
166
+ const added = chunkSize === 2 ? evtBytes : evtBytes + 1;
167
+
168
+ if (chunkSize + added > maxBytes && chunk.length > 0) {
169
+ chunks.push(chunk);
170
+ chunk = [evt];
171
+ chunkSize = 2 + evtBytes;
172
+ } else {
173
+ chunk.push(evt);
174
+ chunkSize += added;
175
+ }
176
+ }
177
+
178
+ if (chunk.length > 0) {
179
+ chunks.push(chunk);
180
+ }
181
+
182
+ return chunks;
183
+ }
184
+
151
185
  /**
152
186
  * POST events to server using Node.js stdlib.
153
187
  */
@@ -227,12 +261,17 @@ async function main() {
227
261
 
228
262
  try {
229
263
  for (const { identity, events: batch } of byDeveloper.values()) {
230
- const result = await postEvents(serverUrl, batch, identity);
231
- for (const evt of batch) {
232
- if (evt.event_id) sentEventIds.add(evt.event_id);
264
+ const chunks = chunkEvents(batch);
265
+ let totalReceived = 0;
266
+ for (const chunk of chunks) {
267
+ const result = await postEvents(serverUrl, chunk, identity);
268
+ totalReceived += result.received;
269
+ for (const evt of chunk) {
270
+ if (evt.event_id) sentEventIds.add(evt.event_id);
271
+ }
233
272
  }
234
273
  const projects = [...new Set(batch.map(e => e.project_path).filter(Boolean))];
235
- log({ msg: 'sent', events: result.received, developer: identity.email, projects, server: serverUrl });
274
+ log({ msg: 'sent', events: totalReceived, chunks: chunks.length, developer: identity.email, projects, server: serverUrl });
236
275
  }
237
276
  commitQueue(sendingPath);
238
277
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.5.0",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {
@@ -10,7 +10,6 @@
10
10
  "bin/",
11
11
  "cli/",
12
12
  "client/",
13
- "mcp-server/",
14
13
  ".commithash",
15
14
  "README.md"
16
15
  ],
@@ -24,11 +23,8 @@
24
23
  "build:dashboard": "npm run --prefix dashboard build",
25
24
  "analyze": "node scripts/analyze-sessions.js"
26
25
  },
27
- "dependencies": {
28
- "@modelcontextprotocol/sdk": "^1.26.0",
29
- "zod": "^3.24.0"
30
- },
31
26
  "devDependencies": {
27
+ "express": "^4.22.1",
32
28
  "vitest": "^3.0.0"
33
29
  }
34
30
  }
@@ -1,186 +0,0 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
- import { z } from "zod";
4
- import { execSync } from "child_process";
5
- import { readFileSync } from "node:fs";
6
- import { join } from "node:path";
7
- import { homedir } from "node:os";
8
-
9
- function loadLensConfig() {
10
- try {
11
- return JSON.parse(readFileSync(join(homedir(), ".ai-lens", "config.json"), "utf-8"));
12
- } catch {
13
- return {};
14
- }
15
- }
16
-
17
- const lensConfig = loadLensConfig();
18
- const SERVER_URL = process.env.AI_LENS_SERVER_URL || lensConfig.serverUrl || "http://168.119.103.228:13300";
19
- const AUTH_TOKEN = process.env.AI_LENS_AUTH_TOKEN || lensConfig.authToken;
20
-
21
- async function apiCall(path) {
22
- const headers = {};
23
- if (AUTH_TOKEN) {
24
- headers["X-Auth-Token"] = AUTH_TOKEN;
25
- }
26
- const res = await fetch(`${SERVER_URL}${path}`, { headers });
27
- if (!res.ok) {
28
- const text = await res.text().catch(() => "");
29
- throw new Error(`API ${res.status}: ${text || res.statusText}`);
30
- }
31
- return res.json();
32
- }
33
-
34
- function textResult(data) {
35
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
36
- }
37
-
38
- const server = new McpServer({ name: "ai-lens", version: "1.0.0" });
39
-
40
- // 1. who_am_i
41
- server.tool(
42
- "who_am_i",
43
- "Identify the current user by their git email. Returns your developer_id, name, and team(s). Call this first to get IDs needed for other tools like get_developer or get_team.",
44
- {},
45
- async () => {
46
- let email;
47
- try {
48
- email = execSync("git config user.email", { encoding: "utf-8" }).trim();
49
- } catch {
50
- return textResult({ error: "Could not resolve git email. Make sure git is configured." });
51
- }
52
-
53
- const developers = await apiCall("/api/developers");
54
- const me = developers.find(
55
- (d) => d.email.toLowerCase() === email.toLowerCase()
56
- );
57
- if (!me) {
58
- return textResult({ error: `No developer found for email: ${email}` });
59
- }
60
-
61
- const detail = await apiCall(`/api/dashboard/developers/${me.id}?days=7`);
62
-
63
- return textResult({
64
- developer_id: me.id,
65
- name: me.name,
66
- email: me.email,
67
- teams: (detail.teams || []).map((t) => ({ id: t.id, name: t.name })),
68
- });
69
- }
70
- );
71
-
72
- // 2. get_overview
73
- server.tool(
74
- "get_overview",
75
- "Get organization-wide KPIs and trends: active developers, adoption rate, total AI hours, MCP server and skill distribution. Use `days` to control the time window (default: 7 days).",
76
- { days: z.number().optional().describe("Time window in days (default: 7)") },
77
- async ({ days }) => {
78
- const data = await apiCall(`/api/dashboard/overview?days=${days ?? 7}`);
79
- return textResult(data);
80
- }
81
- );
82
-
83
- // 3. list_teams
84
- server.tool(
85
- "list_teams",
86
- "List all teams with aggregated stats: member counts, adoption rate, average sessions per developer, total AI hours. Use a team's `id` with get_team or get_team_analysis for details.",
87
- { days: z.number().optional().describe("Time window in days (default: 7)") },
88
- async ({ days }) => {
89
- const data = await apiCall(`/api/dashboard/teams?days=${days ?? 7}`);
90
- return textResult(data);
91
- }
92
- );
93
-
94
- // 4. get_team
95
- server.tool(
96
- "get_team",
97
- "Get detailed team info: KPIs, member list with activity status (active/inactive/never_used), tasks with story points, activity trend, MCP and skill distribution. Use who_am_i to find your team_id first. Each member has `developer_id` you can pass to get_developer.",
98
- {
99
- team_id: z.string().describe("Team ID"),
100
- days: z.number().optional().describe("Time window in days (default: 7)"),
101
- },
102
- async ({ team_id, days }) => {
103
- const data = await apiCall(`/api/dashboard/teams/${team_id}?days=${days ?? 7}`);
104
- return textResult(data);
105
- }
106
- );
107
-
108
- // 5. get_team_analysis
109
- server.tool(
110
- "get_team_analysis",
111
- "Get AI-generated team analysis: summary, key achievements, recurring problems with time wasted and recommendations, unanswered questions patterns, MCP and bash error patterns, and CLAUDE.md suggestions. Each problem includes type, occurrences, affected developers, and actionable recommendation.",
112
- {
113
- team_id: z.string().describe("Team ID"),
114
- days: z.number().optional().describe("Time window in days (default: 7)"),
115
- },
116
- async ({ team_id, days }) => {
117
- const data = await apiCall(`/api/dashboard/teams/${team_id}/analysis?days=${days ?? 7}`);
118
- return textResult(data);
119
- }
120
- );
121
-
122
- // 6. get_developer
123
- server.tool(
124
- "get_developer",
125
- "Get developer profile: session count, AI hours, tasks with story points, MCP and skill usage, recent session chains, and comparison with team averages. Use who_am_i to find your developer_id. Each chain has a `chain_id` you can pass to get_chain for full event history.",
126
- {
127
- developer_id: z.string().describe("Developer ID (UUID)"),
128
- days: z.number().optional().describe("Time window in days (default: 30)"),
129
- },
130
- async ({ developer_id, days }) => {
131
- const data = await apiCall(`/api/dashboard/developers/${developer_id}?days=${days ?? 30}`);
132
- return textResult(data);
133
- }
134
- );
135
-
136
- // 7. get_mcp_distribution
137
- server.tool(
138
- "get_mcp_distribution",
139
- "Get MCP server usage distribution across the organization: which MCP servers are used, how often, by how many developers, in how many sessions.",
140
- { days: z.number().optional().describe("Time window in days (default: 30)") },
141
- async ({ days }) => {
142
- const data = await apiCall(`/api/dashboard/mcp?days=${days ?? 30}`);
143
- return textResult(data);
144
- }
145
- );
146
-
147
- // 8. get_chain
148
- server.tool(
149
- "get_chain",
150
- "Get a session chain (one or more linked sessions) with event history, plan mode segments, and timing. Find chain_id from get_developer's recent_chains list. Events include tool uses, prompts, errors, and raw original payloads. Use `offset` and `limit` to paginate through events (default: first 50). Response includes `event_count` and `has_more` to help with pagination.",
151
- {
152
- chain_id: z.string().describe("Chain ID (UUID)"),
153
- offset: z.number().optional().describe("Skip first N events (default: 0)"),
154
- limit: z.number().optional().describe("Max events to return (default: 50)"),
155
- },
156
- async ({ chain_id, offset, limit }) => {
157
- const data = await apiCall(`/api/dashboard/chains/${chain_id}`);
158
- const off = offset ?? 0;
159
- const lim = limit ?? 50;
160
- const allEvents = data.events || [];
161
- const page = allEvents.slice(off, off + lim);
162
- return textResult({
163
- ...data,
164
- events: page,
165
- event_count: allEvents.length,
166
- events_returned: page.length,
167
- offset: off,
168
- has_more: off + lim < allEvents.length,
169
- });
170
- }
171
- );
172
-
173
- // 9. get_chain_analysis
174
- server.tool(
175
- "get_chain_analysis",
176
- "Get AI-generated analysis for a session chain: what tasks were worked on (with status, complexity, files modified), what went well, problems encountered (with time wasted and recommendations), unanswered questions, and tool errors. Use chain_id from get_developer's recent_chains.",
177
- { chain_id: z.string().describe("Chain ID (UUID)") },
178
- async ({ chain_id }) => {
179
- const data = await apiCall(`/api/dashboard/chains/${chain_id}/analysis`);
180
- return textResult(data);
181
- }
182
- );
183
-
184
- const transport = new StdioServerTransport();
185
- await server.connect(transport);
186
- console.error("AI Lens MCP server running");