agentlytics 0.1.13 → 0.1.15

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.
@@ -0,0 +1,296 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const { getAppDataPath } = require('./base');
5
+
6
+ // ============================================================
7
+ // Kiro editor adapter
8
+ // ============================================================
9
+
10
+ const name = 'kiro';
11
+
12
+ const KIRO_AGENT_DIR = path.join(
13
+ getAppDataPath('Kiro'), 'User', 'globalStorage', 'kiro.kiroagent'
14
+ );
15
+ const WORKSPACE_SESSIONS_DIR = path.join(KIRO_AGENT_DIR, 'workspace-sessions');
16
+
17
+ function getChats() {
18
+ const chats = [];
19
+ if (!fs.existsSync(KIRO_AGENT_DIR)) return chats;
20
+
21
+ // Strategy 1: workspace-sessions (structured, has workspace info)
22
+ if (fs.existsSync(WORKSPACE_SESSIONS_DIR)) {
23
+ try {
24
+ for (const folder of fs.readdirSync(WORKSPACE_SESSIONS_DIR)) {
25
+ const wsDir = path.join(WORKSPACE_SESSIONS_DIR, folder);
26
+ if (!fs.statSync(wsDir).isDirectory()) continue;
27
+
28
+ // Decode base64 folder name to get workspace path
29
+ let workspacePath = null;
30
+ try {
31
+ workspacePath = Buffer.from(folder, 'base64').toString('utf-8');
32
+ } catch {}
33
+
34
+ const indexPath = path.join(wsDir, 'sessions.json');
35
+ let sessions = [];
36
+ try {
37
+ sessions = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
38
+ } catch { continue; }
39
+
40
+ for (const session of sessions) {
41
+ const sessionFile = path.join(wsDir, `${session.sessionId}.json`);
42
+ const exists = fs.existsSync(sessionFile);
43
+
44
+ chats.push({
45
+ source: 'kiro',
46
+ composerId: session.sessionId,
47
+ name: cleanTitle(session.title),
48
+ createdAt: parseInt(session.dateCreated) || null,
49
+ lastUpdatedAt: exists ? getFileMtime(sessionFile) : parseInt(session.dateCreated) || null,
50
+ mode: 'kiro',
51
+ folder: session.workspaceDirectory || workspacePath || null,
52
+ encrypted: false,
53
+ bubbleCount: 0,
54
+ _fullPath: exists ? sessionFile : null,
55
+ _type: 'workspace-session',
56
+ });
57
+ }
58
+ }
59
+ } catch {}
60
+ }
61
+
62
+ // Strategy 2: .chat files in hash directories (individual agent executions)
63
+ // Kiro saves a snapshot of the conversation after each API call, so multiple
64
+ // .chat files can share the same executionId. We group by executionId and
65
+ // keep only the latest snapshot (highest message count) per conversation.
66
+ const seenIds = new Set(chats.map(c => c.composerId));
67
+ const executionMap = new Map(); // executionId -> best candidate
68
+ try {
69
+ for (const dir of fs.readdirSync(KIRO_AGENT_DIR)) {
70
+ // Skip known non-workspace directories
71
+ if (['default', 'dev_data', 'index', 'sessions', 'workspace-sessions'].includes(dir)) continue;
72
+ const fullDir = path.join(KIRO_AGENT_DIR, dir);
73
+ if (!fs.statSync(fullDir).isDirectory()) continue;
74
+
75
+ let files;
76
+ try { files = fs.readdirSync(fullDir).filter(f => f.endsWith('.chat')); } catch { continue; }
77
+
78
+ for (const file of files) {
79
+ const fullPath = path.join(fullDir, file);
80
+ try {
81
+ const stat = fs.statSync(fullPath);
82
+ const meta = peekChatMeta(fullPath);
83
+ const chatId = meta.executionId || `${dir}/${file.replace('.chat', '')}`;
84
+ if (seenIds.has(chatId)) continue;
85
+
86
+ const candidate = {
87
+ source: 'kiro',
88
+ composerId: chatId,
89
+ name: meta.title || null,
90
+ createdAt: meta.startTime || stat.birthtime.getTime(),
91
+ lastUpdatedAt: meta.endTime || stat.mtime.getTime(),
92
+ mode: meta.workflow || 'kiro',
93
+ folder: meta.folder || null,
94
+ encrypted: false,
95
+ bubbleCount: meta.messageCount || 0,
96
+ _fullPath: fullPath,
97
+ _type: 'chat-file',
98
+ };
99
+
100
+ // Keep the snapshot with the most messages per executionId
101
+ if (meta.executionId) {
102
+ const existing = executionMap.get(meta.executionId);
103
+ if (!existing || meta.messageCount > existing.bubbleCount) {
104
+ // Update createdAt to the earliest startTime seen
105
+ if (existing && existing.createdAt < candidate.createdAt) {
106
+ candidate.createdAt = existing.createdAt;
107
+ }
108
+ executionMap.set(meta.executionId, candidate);
109
+ } else if (existing && meta.startTime && meta.startTime < existing.createdAt) {
110
+ existing.createdAt = meta.startTime;
111
+ }
112
+ } else {
113
+ chats.push(candidate);
114
+ }
115
+ } catch {}
116
+ }
117
+ }
118
+ } catch {}
119
+
120
+ // Add the deduplicated execution sessions
121
+ for (const chat of executionMap.values()) {
122
+ chats.push(chat);
123
+ }
124
+
125
+ return chats;
126
+ }
127
+
128
+ function peekChatMeta(filePath) {
129
+ const meta = { title: null, folder: null, startTime: null, endTime: null, workflow: null, messageCount: 0, executionId: null };
130
+ try {
131
+ const raw = fs.readFileSync(filePath, 'utf-8');
132
+ const data = JSON.parse(raw);
133
+
134
+ meta.executionId = data.executionId || null;
135
+
136
+ if (data.metadata) {
137
+ meta.startTime = data.metadata.startTime || null;
138
+ meta.endTime = data.metadata.endTime || null;
139
+ meta.workflow = data.metadata.workflow || null;
140
+ }
141
+
142
+ const chat = data.chat || [];
143
+ for (const msg of chat) {
144
+ if (msg.role === 'human') {
145
+ // Try to extract user request from rules block
146
+ const userReq = extractUserRequest(msg.content);
147
+ if (userReq && !meta.title) {
148
+ meta.title = cleanTitle(userReq);
149
+ }
150
+ }
151
+ if (msg.role === 'bot' || msg.role === 'human') meta.messageCount++;
152
+ }
153
+
154
+ // Try to extract folder from context
155
+ for (const ctx of data.context || []) {
156
+ if (ctx.type === 'steering' && ctx.id) {
157
+ // Extract workspace from steering file path
158
+ const match = ctx.id.match(/file:\/\/(.*?)\/.kiro\//);
159
+ if (match) meta.folder = match[1];
160
+ }
161
+ }
162
+ } catch {}
163
+ return meta;
164
+ }
165
+
166
+ function isSystemPrompt(content) {
167
+ if (typeof content !== 'string') return false;
168
+ return content.startsWith('<identity>') || content.startsWith('# ');
169
+ }
170
+
171
+ function extractUserRequest(content) {
172
+ if (typeof content !== 'string') return null;
173
+ // "## Included Rules" messages contain the actual user request after </user-rule>
174
+ const ruleEnd = content.lastIndexOf('</user-rule>');
175
+ if (ruleEnd >= 0) {
176
+ let userPart = content.substring(ruleEnd + '</user-rule>'.length).trim();
177
+ // Strip trailing EnvironmentContext block
178
+ const envIdx = userPart.indexOf('<EnvironmentContext>');
179
+ if (envIdx >= 0) userPart = userPart.substring(0, envIdx).trim();
180
+ // Strip steering-reminder blocks
181
+ const steerIdx = userPart.indexOf('<steering-reminder>');
182
+ if (steerIdx >= 0) userPart = userPart.substring(0, steerIdx).trim();
183
+ if (userPart) return userPart;
184
+ }
185
+ return null;
186
+ }
187
+
188
+ function getMessages(chat) {
189
+ if (!chat._fullPath || !fs.existsSync(chat._fullPath)) return [];
190
+
191
+ if (chat._type === 'workspace-session') {
192
+ return getWorkspaceSessionMessages(chat._fullPath);
193
+ }
194
+ return getChatFileMessages(chat._fullPath);
195
+ }
196
+
197
+ function getWorkspaceSessionMessages(filePath) {
198
+ const messages = [];
199
+ try {
200
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
201
+ const history = data.history || [];
202
+
203
+ for (const entry of history) {
204
+ const msg = entry.message;
205
+ if (!msg) continue;
206
+
207
+ const role = msg.role === 'user' ? 'user' : msg.role === 'assistant' ? 'assistant' : null;
208
+ if (!role) continue;
209
+
210
+ const content = extractContentFromMessage(msg.content);
211
+ if (!content) continue;
212
+
213
+ const result = { role, content };
214
+
215
+ // Extract model info from promptLogs
216
+ if (role === 'assistant' && entry.promptLogs && entry.promptLogs.length > 0) {
217
+ const log = entry.promptLogs[0];
218
+ result._model = log.modelTitle || null;
219
+ }
220
+
221
+ messages.push(result);
222
+ }
223
+ } catch {}
224
+ return messages;
225
+ }
226
+
227
+ function getChatFileMessages(filePath) {
228
+ const messages = [];
229
+ try {
230
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
231
+ const chat = data.chat || [];
232
+ const model = data.metadata?.modelId || null;
233
+
234
+ for (const msg of chat) {
235
+ if (msg.role === 'human') {
236
+ if (isSystemPrompt(msg.content)) continue;
237
+ // Try extracting user request from rules block first
238
+ const userReq = extractUserRequest(msg.content);
239
+ const content = userReq || extractUserText(msg.content);
240
+ if (content) messages.push({ role: 'user', content });
241
+ } else if (msg.role === 'bot') {
242
+ const content = typeof msg.content === 'string' ? msg.content : '';
243
+ if (content) messages.push({ role: 'assistant', content, _model: model });
244
+ } else if (msg.role === 'tool') {
245
+ const content = typeof msg.content === 'string' ? msg.content : '';
246
+ if (content) messages.push({ role: 'tool', content: content.substring(0, 2000) });
247
+ }
248
+ }
249
+ } catch {}
250
+ return messages;
251
+ }
252
+
253
+ function extractContentFromMessage(content) {
254
+ if (typeof content === 'string') return content;
255
+ if (!Array.isArray(content)) return '';
256
+ return content
257
+ .filter(c => c.type === 'text' || c.type === 'mention')
258
+ .map(c => c.text)
259
+ .join('\n') || '';
260
+ }
261
+
262
+ function extractUserText(content) {
263
+ if (typeof content === 'string') {
264
+ // Skip system prompt content
265
+ if (isSystemPrompt(content)) return null;
266
+ // Strip XML tags and rules blocks
267
+ return cleanTitle(content);
268
+ }
269
+ if (Array.isArray(content)) {
270
+ return content
271
+ .filter(c => c.type === 'text' || c.type === 'mention')
272
+ .map(c => c.text)
273
+ .join('\n') || '';
274
+ }
275
+ return '';
276
+ }
277
+
278
+ function cleanTitle(title) {
279
+ if (!title) return null;
280
+ let clean = title
281
+ .replace(/<[^>]+>[\s\S]*?<\/[^>]+>/g, '')
282
+ .replace(/<[^>]+>/g, '')
283
+ .replace(/## Included Rules[\s\S]*$/m, '')
284
+ .replace(/\s+/g, ' ')
285
+ .trim()
286
+ .substring(0, 120);
287
+ return clean || null;
288
+ }
289
+
290
+ function getFileMtime(filePath) {
291
+ try { return fs.statSync(filePath).mtime.getTime(); } catch { return null; }
292
+ }
293
+
294
+ const labels = { 'kiro': 'Kiro' };
295
+
296
+ module.exports = { name, labels, getChats, getMessages };
@@ -1,103 +1,184 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs');
3
3
  const os = require('os');
4
- const { execSync } = require('child_process');
5
4
 
6
- const DB_PATH = path.join(os.homedir(), '.local', 'share', 'opencode', 'opencode.db');
5
+ // OpenCode stores data in different locations depending on the platform
6
+ // - Windows: %LOCALAPPDATA%\opencode\storage (not Roaming)
7
+ // - macOS/Linux: ~/.local/share/opencode/storage (XDG path)
8
+ function getOpenCodeStoragePath() {
9
+ const home = os.homedir();
10
+ switch (process.platform) {
11
+ case 'win32':
12
+ return path.join(home, 'AppData', 'Local', 'opencode', 'storage');
13
+ case 'darwin':
14
+ case 'linux':
15
+ default:
16
+ return path.join(home, '.local', 'share', 'opencode', 'storage');
17
+ }
18
+ }
19
+
20
+ const STORAGE_DIR = getOpenCodeStoragePath();
21
+ const SESSION_DIR = path.join(STORAGE_DIR, 'session');
22
+ const MESSAGE_DIR = path.join(STORAGE_DIR, 'message');
23
+ const PART_DIR = path.join(STORAGE_DIR, 'part');
7
24
 
8
25
  // ============================================================
9
- // Query SQLite via CLI
26
+ // Scan JSON files from OpenCode storage
10
27
  // ============================================================
11
28
 
12
- function queryDb(sql) {
13
- if (!fs.existsSync(DB_PATH)) return [];
29
+ function readJson(filePath) {
14
30
  try {
15
- const raw = execSync(
16
- `sqlite3 -json ${JSON.stringify(DB_PATH)} ${JSON.stringify(sql)}`,
17
- { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }
18
- );
19
- return JSON.parse(raw);
20
- } catch { return []; }
31
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
32
+ } catch { return null; }
21
33
  }
22
34
 
23
- // ============================================================
24
- // Adapter interface
25
- // ============================================================
35
+ function getAllSessions() {
36
+ const sessions = [];
37
+ if (!fs.existsSync(SESSION_DIR)) return sessions;
26
38
 
27
- const name = 'opencode';
39
+ for (const projectHash of fs.readdirSync(SESSION_DIR)) {
40
+ const projectDir = path.join(SESSION_DIR, projectHash);
41
+ if (!fs.statSync(projectDir).isDirectory()) continue;
28
42
 
29
- function getChats() {
30
- const rows = queryDb('SELECT s.id, s.title, s.directory, s.time_created, s.time_updated, p.worktree, p.name as project_name, (SELECT count(*) FROM message m WHERE m.session_id = s.id) as msg_count FROM session s LEFT JOIN project p ON s.project_id = p.id ORDER BY s.time_updated DESC');
43
+ let files;
44
+ try { files = fs.readdirSync(projectDir).filter(f => f.startsWith('ses_') && f.endsWith('.json')); } catch { continue; }
31
45
 
32
- return rows.map(row => ({
33
- source: 'opencode',
34
- composerId: row.id,
35
- name: cleanTitle(row.title),
36
- createdAt: row.time_created,
37
- lastUpdatedAt: row.time_updated,
38
- mode: 'opencode',
39
- folder: row.worktree || row.directory || null,
40
- encrypted: false,
41
- bubbleCount: row.msg_count || 0,
42
- }));
46
+ for (const file of files) {
47
+ const filePath = path.join(projectDir, file);
48
+ const data = readJson(filePath);
49
+ if (data && data.id) {
50
+ sessions.push({ ...data, _filePath: filePath });
51
+ }
52
+ }
53
+ }
54
+ return sessions;
43
55
  }
44
56
 
45
- function cleanTitle(title) {
46
- if (!title) return null;
47
- // Strip default "New session - <date>" titles
48
- if (title.startsWith('New session - ')) return null;
49
- return title.substring(0, 120) || null;
57
+ function getMessageCount(sessionId) {
58
+ const sessionMsgDir = path.join(MESSAGE_DIR, sessionId);
59
+ if (!fs.existsSync(sessionMsgDir)) return 0;
60
+
61
+ try {
62
+ return fs.readdirSync(sessionMsgDir).filter(f => f.startsWith('msg_') && f.endsWith('.json')).length;
63
+ } catch { return 0; }
50
64
  }
51
65
 
52
- function getMessages(chat) {
53
- // Get messages with their parts joined
54
- const messages = queryDb(`SELECT m.id as msg_id, m.data as msg_data, m.time_created FROM message m WHERE m.session_id = '${chat.composerId}' ORDER BY m.time_created ASC`);
66
+ function getMessagesForSession(sessionId) {
67
+ const sessionMsgDir = path.join(MESSAGE_DIR, sessionId);
68
+ if (!fs.existsSync(sessionMsgDir)) return [];
55
69
 
56
- const result = [];
57
- for (const msg of messages) {
58
- let msgData;
59
- try { msgData = JSON.parse(msg.msg_data); } catch { continue; }
70
+ let files;
71
+ try { files = fs.readdirSync(sessionMsgDir).filter(f => f.startsWith('msg_') && f.endsWith('.json')); } catch { return []; }
60
72
 
61
- const role = msgData.role;
62
- if (!role) continue;
73
+ const messages = [];
74
+ for (const file of files) {
75
+ const msgPath = path.join(sessionMsgDir, file);
76
+ const msg = readJson(msgPath);
77
+ if (!msg || !msg.id) continue;
63
78
 
64
79
  // Get parts for this message
65
- const parts = queryDb(`SELECT data FROM part WHERE message_id = '${msg.msg_id}' ORDER BY time_created ASC`);
80
+ const msgPartDir = path.join(PART_DIR, msg.id);
81
+ const parts = [];
82
+ if (fs.existsSync(msgPartDir)) {
83
+ try {
84
+ const partFiles = fs.readdirSync(msgPartDir).filter(f => f.startsWith('prt_') && f.endsWith('.json'));
85
+ for (const partFile of partFiles) {
86
+ const part = readJson(path.join(msgPartDir, partFile));
87
+ if (part) parts.push(part);
88
+ }
89
+ } catch { /* skip */ }
90
+ }
66
91
 
92
+ // Build content from parts
67
93
  const contentParts = [];
68
94
  for (const part of parts) {
69
- let partData;
70
- try { partData = JSON.parse(part.data); } catch { continue; }
71
-
72
- if (partData.type === 'text' && partData.text) {
73
- contentParts.push(partData.text);
74
- } else if (partData.type === 'tool-use' || partData.type === 'tool_use') {
75
- const toolName = partData.name || partData.toolName || 'tool';
76
- let argKeys = '';
77
- try {
78
- const input = typeof partData.input === 'string' ? JSON.parse(partData.input) : (partData.input || {});
79
- argKeys = Object.keys(input).join(', ');
80
- } catch {}
95
+ const type = part.type;
96
+
97
+ if (type === 'text' && part.text) {
98
+ contentParts.push(part.text);
99
+ } else if (type === 'thinking' || type === 'reasoning') {
100
+ if (part.text) contentParts.push(`[thinking] ${part.text}`);
101
+ } else if (type === 'tool-call' || type === 'tool_use' || type === 'tool') {
102
+ const toolName = part.name || part.toolName || part.tool || 'tool';
103
+ const args = part.args || part.arguments || part.state?.input || {};
104
+ const argKeys = typeof args === 'object' ? Object.keys(args).join(', ') : '';
81
105
  contentParts.push(`[tool-call: ${toolName}(${argKeys})]`);
82
- } else if (partData.type === 'tool-result' || partData.type === 'tool_result') {
83
- const preview = (partData.text || partData.output || '').substring(0, 500);
106
+ } else if (type === 'tool-result' || type === 'tool_result') {
107
+ const preview = (part.text || part.output || part.state?.output || '').substring(0, 500);
84
108
  contentParts.push(`[tool-result] ${preview}`);
109
+ } else if (type === 'step-start' || type === 'step-finish') {
110
+ // Skip metadata parts
85
111
  }
86
- // Skip step-start, step-finish (metadata only)
112
+ }
113
+
114
+ // If no parts with content, check if message itself has content
115
+ if (contentParts.length === 0 && msg.role) {
116
+ contentParts.push(`[${msg.role}]`);
87
117
  }
88
118
 
89
119
  const content = contentParts.join('\n');
90
- if (!content) continue;
91
-
92
- const mappedRole = role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : role;
93
- result.push({
94
- role: mappedRole,
95
- content,
96
- _model: msgData.model?.modelID,
97
- });
120
+ if (content) {
121
+ // Extract model value - handle both string and object formats
122
+ let modelValue = null;
123
+ if (typeof msg.modelID === 'string') {
124
+ modelValue = msg.modelID;
125
+ } else if (msg.model && typeof msg.model === 'object' && msg.model.modelID) {
126
+ modelValue = msg.model.modelID;
127
+ } else if (typeof msg.model === 'string') {
128
+ modelValue = msg.model;
129
+ }
130
+
131
+ messages.push({
132
+ role: msg.role || 'assistant',
133
+ content,
134
+ _model: modelValue,
135
+ _inputTokens: msg.tokens?.input,
136
+ _outputTokens: msg.tokens?.output,
137
+ _cacheRead: msg.tokens?.cache?.read,
138
+ _cacheWrite: msg.tokens?.cache?.write,
139
+ _finish: msg.finish,
140
+ });
141
+ }
98
142
  }
99
143
 
100
- return result;
144
+ // Sort by creation time
145
+ return messages.sort((a, b) => {
146
+ const aTime = a.time?.created || 0;
147
+ const bTime = b.time?.created || 0;
148
+ return aTime - bTime;
149
+ });
101
150
  }
102
151
 
103
- module.exports = { name, getChats, getMessages };
152
+ // ============================================================
153
+ // Adapter interface
154
+ // ============================================================
155
+
156
+ const name = 'opencode';
157
+
158
+ function getChats() {
159
+ const sessions = getAllSessions();
160
+
161
+ return sessions.map(s => ({
162
+ source: 'opencode',
163
+ composerId: s.id,
164
+ name: s.title || null,
165
+ createdAt: s.time?.created || null,
166
+ lastUpdatedAt: s.time?.updated || null,
167
+ mode: s.mode || 'opencode',
168
+ folder: s.directory || null,
169
+ encrypted: false,
170
+ bubbleCount: getMessageCount(s.id),
171
+ _agent: s.agent,
172
+ _model: s.modelID,
173
+ _provider: s.providerID,
174
+ _sessionData: s,
175
+ })).sort((a, b) => (b.lastUpdatedAt || 0) - (a.lastUpdatedAt || 0));
176
+ }
177
+
178
+ function getMessages(chat) {
179
+ return getMessagesForSession(chat.composerId);
180
+ }
181
+
182
+ const labels = { 'opencode': 'OpenCode' };
183
+
184
+ module.exports = { name, labels, getChats, getMessages };
package/editors/vscode.js CHANGED
@@ -315,4 +315,6 @@ function getMessages(chat) {
315
315
  return messages;
316
316
  }
317
317
 
318
- module.exports = { name, getChats, getMessages };
318
+ const labels = { 'vscode': 'VS Code', 'vscode-insiders': 'VS Code Insiders' };
319
+
320
+ module.exports = { name, labels, getChats, getMessages };