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 +1 -1
- package/bin/ai-lens.js +5 -5
- package/cli/init.js +98 -21
- package/client/capture.js +51 -0
- package/client/config.js +56 -1
- package/client/sender.js +43 -4
- package/package.json +2 -6
- package/mcp-server/index.js +0 -207
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
)
|
|
272
|
-
|
|
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
|
-
|
|
296
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
413
|
-
if (
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
441
|
-
if (
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
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:
|
|
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.
|
|
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
|
}
|
package/mcp-server/index.js
DELETED
|
@@ -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");
|