diff --git a/README.md b/README.md index a139b2a..966aa30 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,12 @@

Your Cursor, Windsurf, Claude Code sessions — analyzed, unified, tracked.
- One command to turn scattered AI conversations from 17 editors into a unified analytics dashboard.
Sessions, costs, models, tools — finally in one place. 100% local.
+ One command to turn scattered AI conversations from 18 editors into a unified analytics dashboard.
Sessions, costs, models, tools — finally in one place. 100% local.

npm - editors + editors license node deno @@ -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 ``` @@ -155,6 +155,7 @@ npx agentlytics --collect | **Goose** | ✅ | ✅ | ✅ | ❌ | | **Kiro** | ✅ | ✅ | ✅ | ❌ | | **Codebuff** | ✅ | ✅ | ⚠️ | ⚠️ | +| **Pi Agent** | ✅ | ✅ | ✅ | ✅ | > Windsurf, Windsurf Next, and Antigravity must be running during scan. diff --git a/docs/index.html b/docs/index.html index 87a191f..beb1d7d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,9 +4,9 @@ Agentlytics — Unified analytics for your AI coding agents - + - + diff --git a/editors/index.js b/editors/index.js index e53bfbb..62963cf 100644 --- a/editors/index.js +++ b/editors/index.js @@ -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 = {}; @@ -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(); diff --git a/editors/pi.js b/editors/pi.js new file mode 100644 index 0000000..64213d4 --- /dev/null +++ b/editors/pi.js @@ -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 }; diff --git a/mod.ts b/mod.ts index 13ff3a5..e855b8e 100644 --- a/mod.ts +++ b/mod.ts @@ -908,6 +908,82 @@ function scanCodebuff(): EditorResult { return result; } +// ── Editor: Pi Agent ──────────────────────────────────────── + +function scanPi(): EditorResult { + const result: EditorResult = { name: "pi", label: "Pi Agent", detected: false, sessions: [] }; + const piHome = Deno.env.get("PI_CODING_AGENT_DIR") || join(HOME, ".pi", "agent"); + const sessionsDir = Deno.env.get("PI_CODING_AGENT_SESSION_DIR") || join(piHome, "sessions"); + if (!existsSync(sessionsDir)) return result; + result.detected = true; + + const stack = [sessionsDir]; + const files: string[] = []; + while (stack.length > 0) { + const current = stack.pop()!; + for (const entry of readDirEntries(current)) { + const full = join(current, entry.name); + if (entry.isDirectory) stack.push(full); + else if (entry.isFile && entry.name.endsWith(".jsonl")) files.push(full); + } + } + + for (const filePath of files) { + try { + const lines = readTextSync(filePath).split("\n").filter(Boolean); + if (lines.length === 0) continue; + const header = JSON.parse(lines[0]); + if (header.type !== "session") continue; + + let firstPrompt: string | null = null; + let msgCount = 0; + let lastUpdatedAt: number | null = header.timestamp ? new Date(header.timestamp).getTime() : null; + + for (const line of lines.slice(1)) { + try { + const obj = JSON.parse(line); + const ts = obj.timestamp ? new Date(obj.timestamp).getTime() : null; + if (ts && (!lastUpdatedAt || ts > lastUpdatedAt)) lastUpdatedAt = ts; + if (obj.type === "custom_message") { + if (obj.display !== false) msgCount++; + continue; + } + if (obj.type !== "message" || !obj.message) continue; + const role = obj.message.role; + if (["user", "assistant", "toolResult", "bashExecution", "custom"].includes(role)) msgCount++; + if (!firstPrompt && role === "user") { + firstPrompt = extractPiText(obj.message.content).substring(0, 200); + } + } catch { /* skip line */ } + } + + result.sessions.push({ + source: "pi", + composerId: header.id || basename(filePath).replace(".jsonl", ""), + name: cleanPrompt(firstPrompt), + createdAt: header.timestamp ? new Date(header.timestamp).getTime() : fileBirthtime(filePath), + lastUpdatedAt: lastUpdatedAt || fileMtime(filePath), + mode: "pi", + folder: header.cwd || null, + bubbleCount: msgCount, + messageCount: msgCount, + }); + } catch { /* skip file */ } + } + + return result; +} + +function extractPiText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content.map((c: any) => { + if (c?.type === "text") return c.text || ""; + if (c?.type === "image") return `[image: ${c.mimeType || "image"}]`; + return ""; + }).filter(Boolean).join("\n"); +} + function formatRelative(ts: number | null): string { if (!ts) return ""; const diff = Date.now() - ts; @@ -968,6 +1044,7 @@ ${bold("Full version:")} allResults.push(...scanWindsurf()); allResults.push(scanZed()); allResults.push(scanCodebuff()); + allResults.push(scanPi()); // Gather all sessions const allSessions = allResults.flatMap((r) => r.sessions); diff --git a/package.json b/package.json index a352ff4..714188a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "agentlytics", "version": "0.2.12", - "description": "Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravity, OpenCode, Command Code", + "description": "Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravity, OpenCode, Command Code, Pi Agent", "main": "index.js", "bin": { "agentlytics": "./index.js" @@ -44,7 +44,8 @@ "codex", "analytics", "ai", - "agent" + "agent", + "pi" ], "author": "fkadev", "license": "ISC", diff --git a/ui/src/lib/constants.js b/ui/src/lib/constants.js index 4ba512a..3c17a21 100644 --- a/ui/src/lib/constants.js +++ b/ui/src/lib/constants.js @@ -17,6 +17,7 @@ export const EDITOR_COLORS = { 'goose': '#333333', 'kiro': '#ff9900', 'codebuff': '#44ff00', + 'pi': '#7c3aed', }; export const EDITOR_LABELS = { @@ -38,6 +39,7 @@ export const EDITOR_LABELS = { 'goose': 'Goose', 'kiro': 'Kiro', 'codebuff': 'Codebuff', + 'pi': 'Pi Agent', }; export function editorColor(src) {