ai-lens 0.6.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
- 358f236
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/init.js CHANGED
@@ -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).replace(/\/+$/, '');
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)');
@@ -350,10 +403,12 @@ export default async function init() {
350
403
  blank();
351
404
 
352
405
  // Confirm
353
- const answer = await ask('Proceed? [Y/n] ');
354
- if (answer && answer.toLowerCase() !== 'y') {
355
- info('Aborted.');
356
- return;
406
+ if (!auto) {
407
+ const answer = await ask('Proceed? [Y/n] ');
408
+ if (answer && answer.toLowerCase() !== 'y') {
409
+ info('Aborted.');
410
+ return;
411
+ }
357
412
  }
358
413
  blank();
359
414
 
@@ -400,6 +455,7 @@ export default async function init() {
400
455
 
401
456
  // MCP setup (HTTP transport — auth via OAuth in browser, no token needed)
402
457
  const mcpUrl = `${serverUrl}/mcp`;
458
+ const setupMcp = !flags.noMcp;
403
459
 
404
460
  // Claude Code MCP
405
461
  const claudeDir = join(homedir(), '.claude');
@@ -409,10 +465,24 @@ export default async function init() {
409
465
 
410
466
  if (hasClaudeDir && hasClaudeCli) {
411
467
  heading('MCP Server — Claude Code');
412
- const mcpAnswer = await ask('Set up AI Lens MCP server in Claude Code? [Y/n] ');
413
- if (!mcpAnswer || mcpAnswer.toLowerCase() === 'y') {
414
- const scopeInput = await ask(' Scope — user, local, or project? (Enter = user): ');
415
- const scope = ['local', 'project', 'user'].includes(scopeInput) ? scopeInput : 'user';
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';
482
+ } else {
483
+ const scopeInput = await ask(' Scope — user, local, or project? (Enter = user): ');
484
+ scope = ['local', 'project', 'user'].includes(scopeInput) ? scopeInput : 'user';
485
+ }
416
486
  try {
417
487
  // Remove old stdio-based MCP from all scopes, then add HTTP-based
418
488
  try { execSync('claude mcp remove ai-lens -s local', { stdio: 'ignore' }); } catch {}
@@ -437,8 +507,15 @@ export default async function init() {
437
507
  const cursorDir = join(homedir(), '.cursor');
438
508
  if (existsSync(cursorDir)) {
439
509
  heading('MCP Server — Cursor');
440
- const cursorMcpAnswer = await ask('Set up AI Lens MCP server in Cursor? [Y/n] ');
441
- if (!cursorMcpAnswer || cursorMcpAnswer.toLowerCase() === 'y') {
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) {
442
519
  try {
443
520
  removeCursorMcp();
444
521
  addCursorMcp(mcpUrl);
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.6.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,207 +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
- // 10. search
185
- server.tool(
186
- "search",
187
- "Search across all sessions, tasks, and projects using natural language. Returns matching chains grouped by relevance with snippets. Use chain_id from results with get_chain or get_chain_analysis for full details.",
188
- {
189
- query: z.string().describe("Search query (e.g., 'auth0 device flow', 'migration bug', 'token storage')"),
190
- days: z.number().optional().describe("Time window in days (default: 90)"),
191
- developer_id: z.string().optional().describe("Filter to specific developer (UUID)"),
192
- project: z.string().optional().describe("Filter by project path substring"),
193
- limit: z.number().optional().describe("Max results (default: 20, max: 50)"),
194
- },
195
- async ({ query, days, developer_id, project, limit }) => {
196
- const params = new URLSearchParams({ q: query });
197
- if (days) params.set('days', String(days));
198
- if (developer_id) params.set('developer_id', developer_id);
199
- if (project) params.set('project', project);
200
- if (limit) params.set('limit', String(limit));
201
- return textResult(await apiCall(`/api/dashboard/search?${params}`));
202
- }
203
- );
204
-
205
- const transport = new StdioServerTransport();
206
- await server.connect(transport);
207
- console.error("AI Lens MCP server running");