Skip to content

Commit 8ec9805

Browse files
Add pi support
1 parent 4cba3d8 commit 8ec9805

6 files changed

Lines changed: 310 additions & 7 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66

77
<p align="center">
88
<strong>Your Cursor, Devin, Claude Code sessions — analyzed, unified, tracked.</strong><br>
9-
<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>
9+
<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>
1010
</p>
1111

1212
<p align="center">
1313
<a href="https://www.npmjs.com/package/agentlytics"><img src="https://img.shields.io/npm/v/agentlytics?color=6366f1&label=npm" alt="npm"></a>
14-
<a href="#supported-editors"><img src="https://img.shields.io/badge/editors-17-818cf8" alt="editors"></a>
14+
<a href="#supported-editors"><img src="https://img.shields.io/badge/editors-18-818cf8" alt="editors"></a>
1515
<a href="#license"><img src="https://img.shields.io/badge/license-MIT-green" alt="license"></a>
1616
<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>
1717
</p>
@@ -49,6 +49,7 @@ bunx agentlytics
4949

5050
Opens at **http://localhost:4637**. Requires Node.js ≥ 20.19 or ≥ 22.12, macOS. No data ever leaves your machine.
5151

52+
5253
### Node.js
5354

5455
```
@@ -111,6 +112,7 @@ npx agentlytics --collect
111112
| **Goose** |||||
112113
| **Kiro** |||||
113114
| **Codebuff** ||| ⚠️ | ⚠️ |
115+
| **Pi Agent** |||||
114116

115117
> Devin, Devin Next, and Antigravity must be running during scan.
116118

docs/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
<meta charset="UTF-8">
55
<meta name="viewport" content="width=device-width, initial-scale=1.0">
66
<title>Agentlytics — Unified analytics for your AI coding agents</title>
7-
<meta name="description" content="One command to see what all your AI coding agents have been doing. Sessions, costs, models, tools — across Cursor, Devin, Claude Code, VS Code, and 11 more. 100% local.">
7+
<meta name="description" content="One command to see what all your AI coding agents have been doing. Sessions, costs, models, tools — across Cursor, Devin, Claude Code, VS Code, and 14 more. 100% local.">
88
<meta property="og:title" content="Agentlytics — Unified analytics for your AI coding agents">
9-
<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.">
9+
<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.">
1010
<meta property="og:image" content="https://agentlytics.io/screenshot.png">
1111
<meta name="twitter:card" content="summary_large_image">
1212
<link rel="icon" type="image/svg+xml" href="logo.svg">

editors/index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ const commandcode = require('./commandcode');
1414
const goose = require('./goose');
1515
const kiro = require('./kiro');
1616
const codebuff = require('./codebuff');
17+
const pi = require('./pi');
1718

18-
const editors = [cursor, devin, antigravity, claude, vscode, zed, opencode, codex, gemini, copilot, copilotJetbrains, cursorAgent, commandcode, goose, kiro, codebuff];
19+
const editors = [cursor, devin, antigravity, claude, vscode, zed, opencode, codex, gemini, copilot, copilotJetbrains, cursorAgent, commandcode, goose, kiro, codebuff, pi];
1920

2021
// Build a unified source → display-label map from all editor modules
2122
const editorLabels = {};
@@ -150,6 +151,7 @@ function getAllMCPServers(projectFolders = []) {
150151
{ file: '.vscode/mcp.json', editor: 'vscode', label: 'VS Code' },
151152
{ file: '.gemini/settings.json', editor: 'gemini-cli', label: 'Gemini CLI' },
152153
{ file: '.kiro/settings/mcp.json', editor: 'kiro', label: 'Kiro' },
154+
{ file: '.pi/settings.json', editor: 'pi', label: 'Pi Agent' },
153155
];
154156

155157
const seenProjects = new Set();

editors/pi.js

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
const path = require('path');
2+
const fs = require('fs');
3+
const os = require('os');
4+
const { scanArtifacts, parseMcpConfigFile } = require('./base');
5+
6+
const name = 'pi';
7+
const labels = { pi: 'Pi Agent' };
8+
const PI_HOME = process.env.PI_CODING_AGENT_DIR && process.env.PI_CODING_AGENT_DIR.trim()
9+
? path.resolve(process.env.PI_CODING_AGENT_DIR.trim())
10+
: (process.env.PI_HOME && process.env.PI_HOME.trim()
11+
? path.resolve(process.env.PI_HOME.trim())
12+
: path.join(os.homedir(), '.pi', 'agent'));
13+
const SESSIONS_DIR = process.env.PI_CODING_AGENT_SESSION_DIR && process.env.PI_CODING_AGENT_SESSION_DIR.trim()
14+
? path.resolve(process.env.PI_CODING_AGENT_SESSION_DIR.trim())
15+
: path.join(PI_HOME, 'sessions');
16+
const MAX_TOOL_RESULT_PREVIEW = 500;
17+
18+
function getChats() {
19+
const chats = [];
20+
if (!fs.existsSync(SESSIONS_DIR)) return chats;
21+
22+
for (const filePath of walkJsonlFiles(SESSIONS_DIR)) {
23+
const chat = readChatMetadata(filePath);
24+
if (chat) chats.push(chat);
25+
}
26+
27+
return chats;
28+
}
29+
30+
function getMessages(chat) {
31+
const filePath = chat && chat._filePath;
32+
if (!filePath || !fs.existsSync(filePath)) return [];
33+
return parseSessionMessages(filePath);
34+
}
35+
36+
function walkJsonlFiles(dir) {
37+
const results = [];
38+
const stack = [dir];
39+
40+
while (stack.length > 0) {
41+
const current = stack.pop();
42+
let entries;
43+
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; }
44+
45+
for (const entry of entries) {
46+
const fullPath = path.join(current, entry.name);
47+
if (entry.isDirectory()) stack.push(fullPath);
48+
else if (entry.isFile() && entry.name.endsWith('.jsonl')) results.push(fullPath);
49+
}
50+
}
51+
52+
return results.sort();
53+
}
54+
55+
function readChatMetadata(filePath) {
56+
const lines = readLines(filePath);
57+
if (lines.length === 0) return null;
58+
59+
const header = safeParseJson(lines[0]);
60+
if (!header || header.type !== 'session') return null;
61+
62+
let firstPrompt = null;
63+
let messageCount = 0;
64+
let lastTimestamp = toTimestamp(header.timestamp);
65+
66+
for (let i = 1; i < lines.length; i++) {
67+
const entry = safeParseJson(lines[i]);
68+
if (!entry) continue;
69+
const ts = toTimestamp(entry.timestamp || entry.message?.timestamp);
70+
if (ts && (!lastTimestamp || ts > lastTimestamp)) lastTimestamp = ts;
71+
72+
if (entry.type === 'custom_message') {
73+
if (entry.display !== false) messageCount++;
74+
continue;
75+
}
76+
77+
if (entry.type !== 'message' || !entry.message) continue;
78+
const msg = entry.message;
79+
if (isVisibleRole(msg.role)) messageCount++;
80+
if (!firstPrompt && msg.role === 'user') {
81+
const text = extractTextContent(msg.content);
82+
if (text) firstPrompt = cleanPrompt(text);
83+
}
84+
}
85+
86+
let stat = null;
87+
try { stat = fs.statSync(filePath); } catch {}
88+
89+
const sessionId = header.id || path.basename(filePath, '.jsonl').split('_').pop();
90+
return {
91+
source: 'pi',
92+
composerId: sessionId || path.basename(filePath, '.jsonl'),
93+
name: firstPrompt,
94+
createdAt: toTimestamp(header.timestamp) || (stat ? stat.birthtimeMs : null),
95+
lastUpdatedAt: lastTimestamp || (stat ? stat.mtimeMs : null),
96+
mode: 'pi',
97+
folder: header.cwd || decodeFolderFromPath(filePath),
98+
encrypted: false,
99+
bubbleCount: messageCount,
100+
_filePath: filePath,
101+
_version: header.version || null,
102+
_parentSession: header.parentSession || null,
103+
};
104+
}
105+
106+
function parseSessionMessages(filePath) {
107+
const messages = [];
108+
109+
for (const line of readLines(filePath)) {
110+
const entry = safeParseJson(line);
111+
if (!entry) continue;
112+
113+
if (entry.type === 'model_change') {
114+
const model = [entry.provider, entry.modelId].filter(Boolean).join('/') || entry.modelId || null;
115+
if (model) messages.push({ role: 'system', content: `[model changed to ${model}]`, _model: model });
116+
continue;
117+
}
118+
119+
if (entry.type === 'compaction' && entry.summary) {
120+
messages.push({ role: 'system', content: `[compaction] ${entry.summary}` });
121+
continue;
122+
}
123+
124+
if (entry.type === 'branch_summary' && entry.summary) {
125+
messages.push({ role: 'system', content: `[branch summary] ${entry.summary}` });
126+
continue;
127+
}
128+
129+
if (entry.type === 'custom_message') {
130+
if (entry.display !== false) {
131+
const content = extractTextContent(entry.content);
132+
if (content) messages.push({ role: 'system', content: `[${entry.customType || 'custom'}] ${content}` });
133+
}
134+
continue;
135+
}
136+
137+
if (entry.type !== 'message' || !entry.message) continue;
138+
const msg = entry.message;
139+
140+
if (msg.role === 'user') {
141+
const content = extractTextContent(msg.content);
142+
if (content) messages.push({ role: 'user', content });
143+
continue;
144+
}
145+
146+
if (msg.role === 'assistant') {
147+
const { text, toolCalls } = extractAssistantContent(msg.content);
148+
const usage = normalizeUsage(msg.usage);
149+
if (text || toolCalls.length > 0 || usage) {
150+
messages.push({
151+
role: 'assistant',
152+
content: text || toolCalls.map((tc) => `[tool-call: ${tc.name}]`).join('\n'),
153+
_model: msg.model || null,
154+
_provider: msg.provider || null,
155+
_inputTokens: usage?.input,
156+
_outputTokens: usage?.output,
157+
_cacheRead: usage?.cacheRead,
158+
_cacheWrite: usage?.cacheWrite,
159+
_toolCalls: toolCalls,
160+
});
161+
}
162+
continue;
163+
}
164+
165+
if (msg.role === 'toolResult') {
166+
const content = extractTextContent(msg.content).substring(0, MAX_TOOL_RESULT_PREVIEW);
167+
messages.push({
168+
role: 'tool',
169+
content: `[tool-result: ${msg.toolName || 'tool'}${msg.isError ? ' error' : ''}]${content ? ` ${content}` : ''}`,
170+
});
171+
continue;
172+
}
173+
174+
if (msg.role === 'bashExecution') {
175+
const content = [`$ ${msg.command || ''}`, msg.output || ''].filter(Boolean).join('\n').substring(0, MAX_TOOL_RESULT_PREVIEW);
176+
messages.push({ role: 'tool', content: `[bash-execution] ${content}` });
177+
continue;
178+
}
179+
180+
if (msg.role === 'custom' && msg.display !== false) {
181+
const content = extractTextContent(msg.content);
182+
if (content) messages.push({ role: 'system', content: `[${msg.customType || 'custom'}] ${content}` });
183+
continue;
184+
}
185+
186+
if (msg.role === 'branchSummary' && msg.summary) {
187+
messages.push({ role: 'system', content: `[branch summary] ${msg.summary}` });
188+
} else if (msg.role === 'compactionSummary' && msg.summary) {
189+
messages.push({ role: 'system', content: `[compaction] ${msg.summary}` });
190+
}
191+
}
192+
193+
return messages;
194+
}
195+
196+
function extractAssistantContent(content) {
197+
const parts = [];
198+
const toolCalls = [];
199+
const blocks = Array.isArray(content) ? content : [{ type: 'text', text: String(content || '') }];
200+
201+
for (const block of blocks) {
202+
if (!block) continue;
203+
if (block.type === 'text' && block.text) {
204+
parts.push(block.text);
205+
} else if (block.type === 'thinking' && block.thinking) {
206+
parts.push(`[thinking] ${block.thinking}`);
207+
} else if (block.type === 'toolCall') {
208+
const toolName = block.name || 'tool';
209+
const args = block.arguments || {};
210+
toolCalls.push({ name: toolName, args });
211+
const argKeys = args && typeof args === 'object' ? Object.keys(args).join(', ') : '';
212+
parts.push(`[tool-call: ${toolName}(${argKeys})]`);
213+
}
214+
}
215+
216+
return { text: parts.join('\n'), toolCalls };
217+
}
218+
219+
function extractTextContent(content) {
220+
if (typeof content === 'string') return content;
221+
if (!Array.isArray(content)) return '';
222+
const parts = [];
223+
for (const block of content) {
224+
if (!block) continue;
225+
if (block.type === 'text' && block.text) parts.push(block.text);
226+
else if (block.type === 'image') parts.push(`[image: ${block.mimeType || 'image'}]`);
227+
else if (block.type === 'thinking' && block.thinking) parts.push(`[thinking] ${block.thinking}`);
228+
}
229+
return parts.join('\n');
230+
}
231+
232+
function normalizeUsage(usage) {
233+
if (!usage || typeof usage !== 'object') return null;
234+
return {
235+
input: numberOrNull(usage.input),
236+
output: numberOrNull(usage.output),
237+
cacheRead: numberOrNull(usage.cacheRead),
238+
cacheWrite: numberOrNull(usage.cacheWrite),
239+
};
240+
}
241+
242+
function getArtifacts(folder) {
243+
return scanArtifacts(folder, {
244+
editor: 'pi',
245+
label: 'Pi Agent',
246+
files: ['AGENTS.md', 'CLAUDE.md', '.pi/settings.json', '.pi/SYSTEM.md', '.pi/APPEND_SYSTEM.md'],
247+
dirs: ['.pi/prompts', '.pi/skills', '.pi/extensions', '.pi/themes'],
248+
});
249+
}
250+
251+
function getMCPServers() {
252+
const servers = [];
253+
servers.push(...parseMcpConfigFile(path.join(PI_HOME, 'settings.json'), { editor: 'pi', label: 'Pi Agent', scope: 'global' }));
254+
return servers;
255+
}
256+
257+
function readLines(filePath) {
258+
try { return fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean); } catch { return []; }
259+
}
260+
261+
function safeParseJson(line) {
262+
try { return JSON.parse(line); } catch { return null; }
263+
}
264+
265+
function toTimestamp(value) {
266+
if (!value) return null;
267+
if (typeof value === 'number') return value;
268+
const ts = new Date(value).getTime();
269+
return Number.isFinite(ts) ? ts : null;
270+
}
271+
272+
function numberOrNull(value) {
273+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
274+
}
275+
276+
function isVisibleRole(role) {
277+
return role === 'user' || role === 'assistant' || role === 'toolResult' || role === 'bashExecution' || role === 'custom';
278+
}
279+
280+
function cleanPrompt(text) {
281+
return String(text || '').replace(/\s+/g, ' ').trim().substring(0, 120);
282+
}
283+
284+
function decodeFolderFromPath(filePath) {
285+
const folderName = path.basename(path.dirname(filePath));
286+
if (!folderName.startsWith('--') || !folderName.endsWith('--')) return null;
287+
const inner = folderName.slice(2, -2);
288+
if (!inner) return null;
289+
if (process.platform === 'win32') {
290+
const parts = inner.split('--').filter(Boolean);
291+
if (parts.length > 1 && /^[A-Za-z]$/.test(parts[0])) return `${parts[0]}:\\${parts.slice(1).join('\\')}`;
292+
}
293+
return inner.replace(/--/g, path.sep);
294+
}
295+
296+
module.exports = { name, labels, getChats, getMessages, getArtifacts, getMCPServers };

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "agentlytics",
33
"version": "0.2.13",
4-
"description": "Comprehensive analytics dashboard for AI coding agents — Cursor, Devin, Claude Code, VS Code Copilot, Zed, Antigravity, OpenCode, Command Code",
4+
"description": "Comprehensive analytics dashboard for AI coding agents — Cursor, Devin, Claude Code, VS Code Copilot, Zed, Antigravity, OpenCode, Command Code, Pi Agent",
55
"main": "index.js",
66
"bin": {
77
"agentlytics": "./index.js"
@@ -44,7 +44,8 @@
4444
"codex",
4545
"analytics",
4646
"ai",
47-
"agent"
47+
"agent",
48+
"pi"
4849
],
4950
"author": "fkadev",
5051
"license": "ISC",

ui/src/lib/constants.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const EDITOR_COLORS = {
1818
'goose': '#333333',
1919
'kiro': '#9046FF',
2020
'codebuff': '#44ff00',
21+
'pi': '#7c3aed',
2122
};
2223

2324
export const EDITOR_LABELS = {
@@ -40,6 +41,7 @@ export const EDITOR_LABELS = {
4041
'goose': 'Goose',
4142
'kiro': 'Kiro',
4243
'codebuff': 'Codebuff',
44+
'pi': 'Pi Agent',
4345
};
4446

4547
export function editorColor(src) {

0 commit comments

Comments
 (0)