|
| 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 }; |
0 commit comments