Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@

<p align="center">
<strong>Your Cursor, Windsurf, Claude Code sessions — analyzed, unified, tracked.</strong><br>
<sub>One command to turn scattered AI conversations from <b>17 editors</b> into a unified analytics dashboard.<br>Sessions, costs, models, tools — finally in one place. 100% local.</sub>
<sub>One command to turn scattered AI conversations from <b>18 editors</b> into a unified analytics dashboard.<br>Sessions, costs, models, tools — finally in one place. 100% local.</sub>
</p>

<p align="center">
<a href="https://www.npmjs.com/package/agentlytics"><img src="https://img.shields.io/npm/v/agentlytics?color=6366f1&label=npm" alt="npm"></a>
<a href="#supported-editors"><img src="https://img.shields.io/badge/editors-17-818cf8" alt="editors"></a>
<a href="#supported-editors"><img src="https://img.shields.io/badge/editors-18-818cf8" alt="editors"></a>
<a href="#license"><img src="https://img.shields.io/badge/license-MIT-green" alt="license"></a>
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%E2%89%A520.19%20%7C%20%E2%89%A522.12-brightgreen" alt="node"></a>
<a href="https://deno.land"><img src="https://img.shields.io/badge/deno-%E2%89%A52.0-000?logo=deno" alt="deno"></a>
Expand Down Expand Up @@ -75,7 +75,7 @@ Only `--allow-read` and `--allow-env` are required. No network access, no file w
Sessions 109
Messages 459
Projects 18
Editors 7 of 15 checked
Editors 7 of 18 checked
Date range 2025-04-02 → 2026-03-09
```

Expand Down Expand Up @@ -155,6 +155,7 @@ npx agentlytics --collect
| **Goose** | ✅ | ✅ | ✅ | ❌ |
| **Kiro** | ✅ | ✅ | ✅ | ❌ |
| **Codebuff** | ✅ | ✅ | ⚠️ | ⚠️ |
| **Pi Agent** | ✅ | ✅ | ✅ | ✅ |

> Windsurf, Windsurf Next, and Antigravity must be running during scan.

Expand Down
4 changes: 2 additions & 2 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agentlytics — Unified analytics for your AI coding agents</title>
<meta name="description" content="One command to see what all your AI coding agents have been doing. Sessions, costs, models, tools — across Cursor, Windsurf, Claude Code, VS Code, and 11 more. 100% local.">
<meta name="description" content="One command to see what all your AI coding agents have been doing. Sessions, costs, models, tools — across Cursor, Windsurf, Claude Code, VS Code, and 14 more. 100% local.">
<meta property="og:title" content="Agentlytics — Unified analytics for your AI coding agents">
<meta property="og:description" content="One command to see what all your AI coding agents have been doing. Sessions, costs, models, tools — across 16 editors. 100% local.">
<meta property="og:description" content="One command to see what all your AI coding agents have been doing. Sessions, costs, models, tools — across 18 editors. 100% local.">
<meta property="og:image" content="https://agentlytics.io/screenshot.png">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" type="image/svg+xml" href="logo.svg">
Expand Down
4 changes: 3 additions & 1 deletion editors/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ const commandcode = require('./commandcode');
const goose = require('./goose');
const kiro = require('./kiro');
const codebuff = require('./codebuff');
const pi = require('./pi');

const editors = [cursor, windsurf, antigravity, claude, vscode, zed, opencode, codex, gemini, copilot, cursorAgent, commandcode, goose, kiro, codebuff];
const editors = [cursor, windsurf, antigravity, claude, vscode, zed, opencode, codex, gemini, copilot, cursorAgent, commandcode, goose, kiro, codebuff, pi];

// Build a unified source → display-label map from all editor modules
const editorLabels = {};
Expand Down Expand Up @@ -145,6 +146,7 @@ function getAllMCPServers(projectFolders = []) {
{ file: '.vscode/mcp.json', editor: 'vscode', label: 'VS Code' },
{ file: '.gemini/settings.json', editor: 'gemini-cli', label: 'Gemini CLI' },
{ file: '.kiro/settings/mcp.json', editor: 'kiro', label: 'Kiro' },
{ file: '.pi/settings.json', editor: 'pi', label: 'Pi Agent' },
];

const seenProjects = new Set();
Expand Down
296 changes: 296 additions & 0 deletions editors/pi.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
const path = require('path');
const fs = require('fs');
const os = require('os');
const { scanArtifacts, parseMcpConfigFile } = require('./base');

const name = 'pi';
const labels = { pi: 'Pi Agent' };
const PI_HOME = process.env.PI_CODING_AGENT_DIR && process.env.PI_CODING_AGENT_DIR.trim()
? path.resolve(process.env.PI_CODING_AGENT_DIR.trim())
: (process.env.PI_HOME && process.env.PI_HOME.trim()
? path.resolve(process.env.PI_HOME.trim())
: path.join(os.homedir(), '.pi', 'agent'));
const SESSIONS_DIR = process.env.PI_CODING_AGENT_SESSION_DIR && process.env.PI_CODING_AGENT_SESSION_DIR.trim()
? path.resolve(process.env.PI_CODING_AGENT_SESSION_DIR.trim())
: path.join(PI_HOME, 'sessions');
const MAX_TOOL_RESULT_PREVIEW = 500;

function getChats() {
const chats = [];
if (!fs.existsSync(SESSIONS_DIR)) return chats;

for (const filePath of walkJsonlFiles(SESSIONS_DIR)) {
const chat = readChatMetadata(filePath);
if (chat) chats.push(chat);
}

return chats;
}

function getMessages(chat) {
const filePath = chat && chat._filePath;
if (!filePath || !fs.existsSync(filePath)) return [];
return parseSessionMessages(filePath);
}

function walkJsonlFiles(dir) {
const results = [];
const stack = [dir];

while (stack.length > 0) {
const current = stack.pop();
let entries;
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; }

for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) stack.push(fullPath);
else if (entry.isFile() && entry.name.endsWith('.jsonl')) results.push(fullPath);
}
}

return results.sort();
}

function readChatMetadata(filePath) {
const lines = readLines(filePath);
if (lines.length === 0) return null;

const header = safeParseJson(lines[0]);
if (!header || header.type !== 'session') return null;

let firstPrompt = null;
let messageCount = 0;
let lastTimestamp = toTimestamp(header.timestamp);

for (let i = 1; i < lines.length; i++) {
const entry = safeParseJson(lines[i]);
if (!entry) continue;
const ts = toTimestamp(entry.timestamp || entry.message?.timestamp);
if (ts && (!lastTimestamp || ts > lastTimestamp)) lastTimestamp = ts;

if (entry.type === 'custom_message') {
if (entry.display !== false) messageCount++;
continue;
}

if (entry.type !== 'message' || !entry.message) continue;
const msg = entry.message;
if (isVisibleRole(msg.role)) messageCount++;
if (!firstPrompt && msg.role === 'user') {
const text = extractTextContent(msg.content);
if (text) firstPrompt = cleanPrompt(text);
}
}

let stat = null;
try { stat = fs.statSync(filePath); } catch {}

const sessionId = header.id || path.basename(filePath, '.jsonl').split('_').pop();
return {
source: 'pi',
composerId: sessionId || path.basename(filePath, '.jsonl'),
name: firstPrompt,
createdAt: toTimestamp(header.timestamp) || (stat ? stat.birthtimeMs : null),
lastUpdatedAt: lastTimestamp || (stat ? stat.mtimeMs : null),
mode: 'pi',
folder: header.cwd || decodeFolderFromPath(filePath),
encrypted: false,
bubbleCount: messageCount,
_filePath: filePath,
_version: header.version || null,
_parentSession: header.parentSession || null,
};
}

function parseSessionMessages(filePath) {
const messages = [];

for (const line of readLines(filePath)) {
const entry = safeParseJson(line);
if (!entry) continue;

if (entry.type === 'model_change') {
const model = [entry.provider, entry.modelId].filter(Boolean).join('/') || entry.modelId || null;
if (model) messages.push({ role: 'system', content: `[model changed to ${model}]`, _model: model });
continue;
}

if (entry.type === 'compaction' && entry.summary) {
messages.push({ role: 'system', content: `[compaction] ${entry.summary}` });
continue;
}

if (entry.type === 'branch_summary' && entry.summary) {
messages.push({ role: 'system', content: `[branch summary] ${entry.summary}` });
continue;
}

if (entry.type === 'custom_message') {
if (entry.display !== false) {
const content = extractTextContent(entry.content);
if (content) messages.push({ role: 'system', content: `[${entry.customType || 'custom'}] ${content}` });
}
continue;
}

if (entry.type !== 'message' || !entry.message) continue;
const msg = entry.message;

if (msg.role === 'user') {
const content = extractTextContent(msg.content);
if (content) messages.push({ role: 'user', content });
continue;
}

if (msg.role === 'assistant') {
const { text, toolCalls } = extractAssistantContent(msg.content);
const usage = normalizeUsage(msg.usage);
if (text || toolCalls.length > 0 || usage) {
messages.push({
role: 'assistant',
content: text || toolCalls.map((tc) => `[tool-call: ${tc.name}]`).join('\n'),
_model: msg.model || null,
_provider: msg.provider || null,
_inputTokens: usage?.input,
_outputTokens: usage?.output,
_cacheRead: usage?.cacheRead,
_cacheWrite: usage?.cacheWrite,
_toolCalls: toolCalls,
});
}
continue;
}

if (msg.role === 'toolResult') {
const content = extractTextContent(msg.content).substring(0, MAX_TOOL_RESULT_PREVIEW);
messages.push({
role: 'tool',
content: `[tool-result: ${msg.toolName || 'tool'}${msg.isError ? ' error' : ''}]${content ? ` ${content}` : ''}`,
});
continue;
}

if (msg.role === 'bashExecution') {
const content = [`$ ${msg.command || ''}`, msg.output || ''].filter(Boolean).join('\n').substring(0, MAX_TOOL_RESULT_PREVIEW);
messages.push({ role: 'tool', content: `[bash-execution] ${content}` });
continue;
}

if (msg.role === 'custom' && msg.display !== false) {
const content = extractTextContent(msg.content);
if (content) messages.push({ role: 'system', content: `[${msg.customType || 'custom'}] ${content}` });
continue;
}

if (msg.role === 'branchSummary' && msg.summary) {
messages.push({ role: 'system', content: `[branch summary] ${msg.summary}` });
} else if (msg.role === 'compactionSummary' && msg.summary) {
messages.push({ role: 'system', content: `[compaction] ${msg.summary}` });
}
}

return messages;
}

function extractAssistantContent(content) {
const parts = [];
const toolCalls = [];
const blocks = Array.isArray(content) ? content : [{ type: 'text', text: String(content || '') }];

for (const block of blocks) {
if (!block) continue;
if (block.type === 'text' && block.text) {
parts.push(block.text);
} else if (block.type === 'thinking' && block.thinking) {
parts.push(`[thinking] ${block.thinking}`);
} else if (block.type === 'toolCall') {
const toolName = block.name || 'tool';
const args = block.arguments || {};
toolCalls.push({ name: toolName, args });
const argKeys = args && typeof args === 'object' ? Object.keys(args).join(', ') : '';
parts.push(`[tool-call: ${toolName}(${argKeys})]`);
}
}

return { text: parts.join('\n'), toolCalls };
}

function extractTextContent(content) {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
const parts = [];
for (const block of content) {
if (!block) continue;
if (block.type === 'text' && block.text) parts.push(block.text);
else if (block.type === 'image') parts.push(`[image: ${block.mimeType || 'image'}]`);
else if (block.type === 'thinking' && block.thinking) parts.push(`[thinking] ${block.thinking}`);
}
return parts.join('\n');
}

function normalizeUsage(usage) {
if (!usage || typeof usage !== 'object') return null;
return {
input: numberOrNull(usage.input),
output: numberOrNull(usage.output),
cacheRead: numberOrNull(usage.cacheRead),
cacheWrite: numberOrNull(usage.cacheWrite),
};
}

function getArtifacts(folder) {
return scanArtifacts(folder, {
editor: 'pi',
label: 'Pi Agent',
files: ['AGENTS.md', 'CLAUDE.md', '.pi/settings.json', '.pi/SYSTEM.md', '.pi/APPEND_SYSTEM.md'],
dirs: ['.pi/prompts', '.pi/skills', '.pi/extensions', '.pi/themes'],
});
}

function getMCPServers() {
const servers = [];
servers.push(...parseMcpConfigFile(path.join(PI_HOME, 'settings.json'), { editor: 'pi', label: 'Pi Agent', scope: 'global' }));
return servers;
}

function readLines(filePath) {
try { return fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean); } catch { return []; }
}

function safeParseJson(line) {
try { return JSON.parse(line); } catch { return null; }
}

function toTimestamp(value) {
if (!value) return null;
if (typeof value === 'number') return value;
const ts = new Date(value).getTime();
return Number.isFinite(ts) ? ts : null;
}

function numberOrNull(value) {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}

function isVisibleRole(role) {
return role === 'user' || role === 'assistant' || role === 'toolResult' || role === 'bashExecution' || role === 'custom';
}

function cleanPrompt(text) {
return String(text || '').replace(/\s+/g, ' ').trim().substring(0, 120);
}

function decodeFolderFromPath(filePath) {
const folderName = path.basename(path.dirname(filePath));
if (!folderName.startsWith('--') || !folderName.endsWith('--')) return null;
const inner = folderName.slice(2, -2);
if (!inner) return null;
if (process.platform === 'win32') {
const parts = inner.split('--').filter(Boolean);
if (parts.length > 1 && /^[A-Za-z]$/.test(parts[0])) return `${parts[0]}:\\${parts.slice(1).join('\\')}`;
}
return inner.replace(/--/g, path.sep);
}

module.exports = { name, labels, getChats, getMessages, getArtifacts, getMCPServers };
Loading