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 +1 -1
- package/bin/ai-lens.js +5 -5
- package/cli/hooks.js +120 -1
- package/cli/init.js +143 -38
- package/cli/remove.js +45 -1
- 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 -186
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/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
|
|
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
|
-
|
|
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)');
|
|
@@ -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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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 (
|
|
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
|
-
|
|
406
|
-
if (
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
|
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
|
|
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.
|
|
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,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");
|