agentlytics 0.1.14 → 0.1.16

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.
@@ -1,103 +1,182 @@
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
+ });
150
+ }
151
+
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);
101
180
  }
102
181
 
103
182
  const labels = { 'opencode': 'OpenCode' };
package/editors/vscode.js CHANGED
@@ -315,6 +315,75 @@ function getMessages(chat) {
315
315
  return messages;
316
316
  }
317
317
 
318
+ // ============================================================
319
+ // Usage / quota data from GitHub Copilot internal API
320
+ // ============================================================
321
+
322
+ function getCopilotToken() {
323
+ const appsPath = path.join(os.homedir(), '.config', 'github-copilot', 'apps.json');
324
+ try {
325
+ if (!fs.existsSync(appsPath)) return null;
326
+ const data = JSON.parse(fs.readFileSync(appsPath, 'utf-8'));
327
+ // Pick the first available oauth_token
328
+ for (const entry of Object.values(data)) {
329
+ if (entry.oauth_token) return { token: entry.oauth_token, user: entry.user || null };
330
+ }
331
+ } catch {}
332
+ return null;
333
+ }
334
+
335
+ function fetchCopilotStatus(token) {
336
+ return new Promise((resolve) => {
337
+ const https = require('https');
338
+ const req = https.get('https://api.github.com/copilot_internal/v2/token', {
339
+ headers: {
340
+ 'Authorization': `token ${token}`,
341
+ 'Accept': 'application/json',
342
+ 'User-Agent': 'agentlytics/1.0',
343
+ },
344
+ timeout: 10000,
345
+ }, (res) => {
346
+ let data = '';
347
+ res.on('data', (chunk) => { data += chunk; });
348
+ res.on('end', () => {
349
+ try { resolve(JSON.parse(data)); } catch { resolve(null); }
350
+ });
351
+ });
352
+ req.on('error', () => resolve(null));
353
+ req.on('timeout', () => { req.destroy(); resolve(null); });
354
+ });
355
+ }
356
+
357
+ async function getUsage() {
358
+ const creds = getCopilotToken();
359
+ if (!creds) return null;
360
+
361
+ const status = await fetchCopilotStatus(creds.token);
362
+ if (!status || status.message) return null;
363
+
364
+ return {
365
+ source: 'vscode',
366
+ plan: {
367
+ name: status.sku || null,
368
+ individual: status.individual || false,
369
+ },
370
+ features: {
371
+ chat: status.chat_enabled || false,
372
+ codeReview: status.code_review_enabled || false,
373
+ agentMode: status.agent_mode_auto_approval || false,
374
+ xcode: status.xcode || false,
375
+ mcp: status.mcp || false,
376
+ },
377
+ limits: {
378
+ quotas: status.limited_user_quotas || null,
379
+ resetDate: status.limited_user_reset_date || null,
380
+ },
381
+ user: {
382
+ login: creds.user || null,
383
+ },
384
+ };
385
+ }
386
+
318
387
  const labels = { 'vscode': 'VS Code', 'vscode-insiders': 'VS Code Insiders' };
319
388
 
320
- module.exports = { name, labels, getChats, getMessages };
389
+ module.exports = { name, labels, getChats, getMessages, getUsage };