memhouse 0.0.1 → 0.3.0

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.
Files changed (96) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +59 -3
  3. package/bin/memhouse.js +487 -0
  4. package/cache.js +850 -0
  5. package/ch-ingest.js +329 -0
  6. package/ch-schema.js +152 -0
  7. package/clickhouse.js +87 -0
  8. package/editors/antigravity.js +1019 -0
  9. package/editors/base.js +321 -0
  10. package/editors/claude.js +386 -0
  11. package/editors/codebuff.js +338 -0
  12. package/editors/codex.js +537 -0
  13. package/editors/commandcode.js +166 -0
  14. package/editors/copilot-jetbrains.js +207 -0
  15. package/editors/copilot.js +260 -0
  16. package/editors/cursor-agent.js +202 -0
  17. package/editors/cursor.js +438 -0
  18. package/editors/gemini.js +231 -0
  19. package/editors/goose.js +358 -0
  20. package/editors/gsd.js +366 -0
  21. package/editors/index.js +179 -0
  22. package/editors/kiro.js +315 -0
  23. package/editors/opencode.js +374 -0
  24. package/editors/vscode.js +413 -0
  25. package/editors/windsurf.js +634 -0
  26. package/editors/zed.js +213 -0
  27. package/index.js +424 -2
  28. package/mcp-server.js +294 -0
  29. package/mem-house/DESIGN.md +119 -0
  30. package/mem-house/delivery/AGENT-INSTALL.md +75 -0
  31. package/mem-house/delivery/PROMPT.md +42 -0
  32. package/mem-house/delivery/install.sh +181 -0
  33. package/mem-house/delivery/kernel-install.md +76 -0
  34. package/mem-house/delivery/plugin/.claude-plugin/plugin.json +8 -0
  35. package/mem-house/delivery/skills/memhouse-search/SKILL.md +104 -0
  36. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +94 -0
  37. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +115 -0
  38. package/mem-house/rls.sql +17 -0
  39. package/mem-house/schema.sql +145 -0
  40. package/mem-house/server/queries.js +878 -0
  41. package/mem-house/server/server.js +228 -0
  42. package/mem-house/shipper/ship.js +402 -0
  43. package/misc/syntax-check.js +38 -0
  44. package/package.json +56 -7
  45. package/pricing.js +88 -0
  46. package/pricing.json +992 -0
  47. package/public/assets/index-BsgjOSTv.js +75 -0
  48. package/public/assets/index-DV6ONi_F.css +2 -0
  49. package/public/index.html +16 -0
  50. package/relay-client.js +306 -0
  51. package/relay-server.js +577 -0
  52. package/server.js +860 -0
  53. package/share-image.js +367 -0
  54. package/ui/eslint.config.js +29 -0
  55. package/ui/index.html +15 -0
  56. package/ui/package-lock.json +4229 -0
  57. package/ui/package.json +38 -0
  58. package/ui/src/App.jsx +364 -0
  59. package/ui/src/components/ActivityHeatmap.jsx +206 -0
  60. package/ui/src/components/AiAuditCard.jsx +254 -0
  61. package/ui/src/components/AnimatedLoader.jsx +14 -0
  62. package/ui/src/components/AnimatedLogo.jsx +96 -0
  63. package/ui/src/components/ChatSidebar.jsx +226 -0
  64. package/ui/src/components/DateRangePicker.jsx +104 -0
  65. package/ui/src/components/EditorDot.jsx +11 -0
  66. package/ui/src/components/EditorIcon.jsx +117 -0
  67. package/ui/src/components/KpiCard.jsx +9 -0
  68. package/ui/src/components/LiveFeed.jsx +128 -0
  69. package/ui/src/components/LoginScreen.jsx +81 -0
  70. package/ui/src/components/MessageRenderer.jsx +148 -0
  71. package/ui/src/components/PageHeader.jsx +11 -0
  72. package/ui/src/components/SectionTitle.jsx +3 -0
  73. package/ui/src/components/ShareModal.jsx +305 -0
  74. package/ui/src/components/TokenTimeline.jsx +258 -0
  75. package/ui/src/hooks/useLive.jsx +15 -0
  76. package/ui/src/index.css +83 -0
  77. package/ui/src/lib/api.js +343 -0
  78. package/ui/src/lib/constants.js +88 -0
  79. package/ui/src/lib/theme.jsx +23 -0
  80. package/ui/src/main.jsx +16 -0
  81. package/ui/src/pages/Artifacts.jsx +575 -0
  82. package/ui/src/pages/Compare.jsx +232 -0
  83. package/ui/src/pages/CostAnalysis.jsx +354 -0
  84. package/ui/src/pages/Dashboard.jsx +529 -0
  85. package/ui/src/pages/DeepAnalysis.jsx +495 -0
  86. package/ui/src/pages/GSD.jsx +726 -0
  87. package/ui/src/pages/MCPs.jsx +744 -0
  88. package/ui/src/pages/ProjectDetail.jsx +325 -0
  89. package/ui/src/pages/Projects.jsx +249 -0
  90. package/ui/src/pages/RelayDashboard.jsx +590 -0
  91. package/ui/src/pages/RelayUserDetail.jsx +466 -0
  92. package/ui/src/pages/Sessions.jsx +535 -0
  93. package/ui/src/pages/Settings.jsx +241 -0
  94. package/ui/src/pages/SqlViewer.jsx +344 -0
  95. package/ui/src/pages/Subscriptions.jsx +411 -0
  96. package/ui/vite.config.js +18 -0
@@ -0,0 +1,321 @@
1
+ const path = require('path');
2
+ const os = require('os');
3
+ const fs = require('fs');
4
+
5
+ const HOME = os.homedir();
6
+
7
+ // --- Permission check ---
8
+
9
+ function isSubscriptionAccessAllowed() {
10
+ try {
11
+ const configPath = path.join(HOME, '.agentlytics', 'config.json');
12
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
13
+ return config.allowSubscriptionAccess === true;
14
+ } catch { return false; }
15
+ }
16
+
17
+ // --- Platform utilities ---
18
+
19
+ /**
20
+ * Get platform-specific app data directory path for VS Code-like editors.
21
+ * - macOS: ~/Library/Application Support/{appName}/User/...
22
+ * - Windows: ~/AppData/Roaming/{appName}/User/...
23
+ * - Linux: ~/.config/{appName}/User/...
24
+ */
25
+ function getAppDataPath(appName) {
26
+ switch (process.platform) {
27
+ case 'darwin':
28
+ return path.join(HOME, 'Library', 'Application Support', appName);
29
+ case 'win32':
30
+ return path.join(HOME, 'AppData', 'Roaming', appName);
31
+ default: // linux, etc.
32
+ return path.join(HOME, '.config', appName);
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Every editor adapter must implement:
38
+ *
39
+ * name - string identifier (e.g. 'cursor', 'devin')
40
+ * getChats() - returns array of chat objects:
41
+ * { source, composerId, name, createdAt, lastUpdatedAt, mode, folder, bubbleCount, encrypted }
42
+ * getMessages(chat) - returns array of message objects:
43
+ * { role: 'user'|'assistant'|'system'|'tool', content: string|Array }
44
+ */
45
+
46
+ /**
47
+ * Scan a project folder for artifact files.
48
+ *
49
+ * @param {string} folder - Absolute path to the project folder
50
+ * @param {Object} opts
51
+ * @param {string} opts.editor - Editor identifier (e.g. 'cursor', 'claude-code')
52
+ * @param {string} opts.label - Display label (e.g. 'Cursor', 'Claude Code')
53
+ * @param {string[]} [opts.files] - Relative file paths to check (e.g. ['CLAUDE.md'])
54
+ * @param {string[]} [opts.dirs] - Relative directories to scan for .md/.yaml/.yml/.json files
55
+ * @returns {Array} Array of artifact objects
56
+ */
57
+ function scanArtifacts(folder, { editor, label, files = [], dirs = [] }) {
58
+ const fs = require('fs');
59
+ const artifacts = [];
60
+ if (!folder || !fs.existsSync(folder)) return artifacts;
61
+
62
+ for (const relPath of files) {
63
+ const filePath = path.join(folder, relPath);
64
+ try {
65
+ const stat = fs.statSync(filePath);
66
+ if (!stat.isFile()) continue;
67
+ const content = fs.readFileSync(filePath, 'utf-8');
68
+ artifacts.push({
69
+ editor,
70
+ editorLabel: label,
71
+ name: relPath,
72
+ path: filePath,
73
+ relativePath: relPath,
74
+ size: stat.size,
75
+ modifiedAt: stat.mtime.getTime(),
76
+ preview: content.substring(0, 500),
77
+ lines: content.split('\n').length,
78
+ });
79
+ } catch { /* skip */ }
80
+ }
81
+
82
+ const isArtifactFile = (f) =>
83
+ f.endsWith('.md') || f.endsWith('.mdc') || f.endsWith('.yaml') || f.endsWith('.yml') || f.endsWith('.json');
84
+
85
+ const addFile = (filePath, relPath, fileName) => {
86
+ try {
87
+ const fstat = fs.statSync(filePath);
88
+ if (!fstat.isFile()) return;
89
+ const content = fs.readFileSync(filePath, 'utf-8');
90
+ artifacts.push({
91
+ editor,
92
+ editorLabel: label,
93
+ name: fileName,
94
+ path: filePath,
95
+ relativePath: relPath,
96
+ size: fstat.size,
97
+ modifiedAt: fstat.mtime.getTime(),
98
+ preview: content.substring(0, 500),
99
+ lines: content.split('\n').length,
100
+ });
101
+ } catch { /* skip */ }
102
+ };
103
+
104
+ for (const dir of dirs) {
105
+ const dirPath = path.join(folder, dir);
106
+ try {
107
+ const stat = fs.statSync(dirPath);
108
+ if (!stat.isDirectory()) continue;
109
+ const entries = fs.readdirSync(dirPath);
110
+ for (const entry of entries) {
111
+ const entryPath = path.join(dirPath, entry);
112
+ if (isArtifactFile(entry)) {
113
+ addFile(entryPath, path.join(dir, entry), entry);
114
+ } else {
115
+ // Recurse one level into subdirectories (e.g. .kiro/specs/<name>/, .windsurf/skills/<name>/)
116
+ try {
117
+ const eStat = fs.statSync(entryPath);
118
+ if (!eStat.isDirectory()) continue;
119
+ const subEntries = fs.readdirSync(entryPath).filter(isArtifactFile);
120
+ for (const subFile of subEntries) {
121
+ addFile(path.join(entryPath, subFile), path.join(dir, entry, subFile), subFile);
122
+ }
123
+ } catch { /* skip */ }
124
+ }
125
+ }
126
+ } catch { /* skip */ }
127
+ }
128
+
129
+ return artifacts;
130
+ }
131
+
132
+ /**
133
+ * Parse a standard MCP config JSON file (mcpServers format).
134
+ * Returns array of { name, command, args, env, envKeys, url, transport, disabled }.
135
+ */
136
+ function parseMcpConfigFile(filePath, { editor, label, scope }) {
137
+ const fs = require('fs');
138
+ if (!filePath || !fs.existsSync(filePath)) return [];
139
+ try {
140
+ const raw = fs.readFileSync(filePath, 'utf-8');
141
+ const data = JSON.parse(raw);
142
+ const servers = data.mcpServers || data.mcp_servers || data.servers || {};
143
+ return Object.entries(servers).map(([name, cfg]) => ({
144
+ name,
145
+ editor,
146
+ editorLabel: label,
147
+ scope,
148
+ configPath: filePath,
149
+ command: cfg.command || null,
150
+ args: cfg.args || [],
151
+ _env: cfg.env || {},
152
+ env: cfg.env ? Object.keys(cfg.env) : [],
153
+ url: cfg.url || null,
154
+ transport: cfg.url ? (cfg.transport || 'http') : (cfg.transport || 'stdio'),
155
+ disabled: cfg.disabled || false,
156
+ disabledTools: cfg.disabledTools || [],
157
+ }));
158
+ } catch { return []; }
159
+ }
160
+
161
+ /**
162
+ * Query an MCP server for its tools list via JSON-RPC 2.0.
163
+ * For stdio servers: spawns the command and communicates via stdin/stdout.
164
+ * For HTTP servers: sends POST to the URL.
165
+ * Returns a Promise<string[]> of tool names, or [] on failure.
166
+ * Timeout: 10s per server.
167
+ */
168
+ function queryMcpServerTools(server) {
169
+ const TIMEOUT = 10000;
170
+
171
+ if (server.url && !server.command) {
172
+ // HTTP/SSE transport — send JSON-RPC via POST
173
+ return queryMcpServerToolsHttp(server.url, TIMEOUT);
174
+ }
175
+
176
+ if (!server.command) return Promise.resolve([]);
177
+
178
+ // stdio transport — spawn the process
179
+ return queryMcpServerToolsStdio(server, TIMEOUT);
180
+ }
181
+
182
+ function queryMcpServerToolsHttp(url, timeout) {
183
+ return new Promise((resolve) => {
184
+ const controller = new AbortController();
185
+ const timer = setTimeout(() => { controller.abort(); resolve([]); }, timeout);
186
+ const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream' };
187
+
188
+ const parseToolsFromResponse = async (r) => {
189
+ const ct = r.headers.get('content-type') || '';
190
+ const text = await r.text();
191
+ if (ct.includes('text/event-stream')) {
192
+ // Parse SSE: lines starting with "data: "
193
+ for (const line of text.split('\n')) {
194
+ if (!line.startsWith('data: ')) continue;
195
+ try {
196
+ const msg = JSON.parse(line.slice(6));
197
+ if (msg.result && msg.result.tools) return msg.result.tools.map(t => t.name);
198
+ } catch {}
199
+ }
200
+ return [];
201
+ }
202
+ try {
203
+ const msg = JSON.parse(text);
204
+ return (msg.result && msg.result.tools) ? msg.result.tools.map(t => t.name) : [];
205
+ } catch { return []; }
206
+ };
207
+
208
+ // 1. Initialize
209
+ fetch(url, {
210
+ method: 'POST', headers,
211
+ body: JSON.stringify({
212
+ jsonrpc: '2.0', id: 1, method: 'initialize',
213
+ params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'agentlytics', version: '1.0.0' } },
214
+ }),
215
+ signal: controller.signal,
216
+ })
217
+ .then(async (r) => {
218
+ const sessionId = r.headers.get('mcp-session-id');
219
+ const h = { ...headers };
220
+ if (sessionId) h['mcp-session-id'] = sessionId;
221
+ await r.text(); // consume body
222
+
223
+ // 2. Send initialized notification
224
+ await fetch(url, {
225
+ method: 'POST', headers: h,
226
+ body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
227
+ signal: controller.signal,
228
+ }).then(r2 => r2.text());
229
+
230
+ // 3. Request tools/list
231
+ const r3 = await fetch(url, {
232
+ method: 'POST', headers: h,
233
+ body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }),
234
+ signal: controller.signal,
235
+ });
236
+
237
+ clearTimeout(timer);
238
+ resolve(await parseToolsFromResponse(r3));
239
+ })
240
+ .catch(() => { clearTimeout(timer); resolve([]); });
241
+ });
242
+ }
243
+
244
+ function queryMcpServerToolsStdio(server, timeout) {
245
+ const { spawn } = require('child_process');
246
+
247
+ return new Promise((resolve) => {
248
+ const env = { ...process.env, ...(server._env || {}) };
249
+ let child;
250
+ try {
251
+ child = spawn(server.command, server.args || [], {
252
+ env, stdio: ['pipe', 'pipe', 'pipe'],
253
+ });
254
+ } catch { return resolve([]); }
255
+
256
+ let stdout = '';
257
+ let done = false;
258
+ let initReceived = false;
259
+
260
+ const finish = (tools) => {
261
+ if (done) return;
262
+ done = true;
263
+ clearTimeout(timer);
264
+ try { child.kill(); } catch {}
265
+ resolve(tools);
266
+ };
267
+
268
+ const timer = setTimeout(() => finish([]), timeout);
269
+
270
+ child.stdout.on('data', (chunk) => {
271
+ stdout += chunk.toString();
272
+ // Parse newline-delimited JSON-RPC responses
273
+ const lines = stdout.split('\n');
274
+ // Keep the last (possibly incomplete) line in the buffer
275
+ stdout = lines.pop() || '';
276
+ for (const line of lines) {
277
+ const trimmed = line.trim();
278
+ if (!trimmed) continue;
279
+ try {
280
+ const msg = JSON.parse(trimmed);
281
+ // Got initialize response — now send initialized + tools/list
282
+ if (msg.id === 1 && msg.result && !initReceived) {
283
+ initReceived = true;
284
+ try {
285
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
286
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }) + '\n');
287
+ } catch { finish([]); }
288
+ }
289
+ // Got tools/list response
290
+ if (msg.id === 2 && msg.result && msg.result.tools) {
291
+ finish(msg.result.tools.map(t => t.name));
292
+ return;
293
+ }
294
+ } catch { /* incomplete JSON, skip */ }
295
+ }
296
+ });
297
+
298
+ child.on('error', () => finish([]));
299
+ child.on('exit', () => finish([]));
300
+
301
+ // Send initialize
302
+ const initMsg = JSON.stringify({
303
+ jsonrpc: '2.0', id: 1, method: 'initialize',
304
+ params: {
305
+ protocolVersion: '2024-11-05',
306
+ capabilities: {},
307
+ clientInfo: { name: 'agentlytics', version: '1.0.0' },
308
+ },
309
+ }) + '\n';
310
+
311
+ try { child.stdin.write(initMsg); } catch { finish([]); }
312
+ });
313
+ }
314
+
315
+ module.exports = {
316
+ getAppDataPath,
317
+ isSubscriptionAccessAllowed,
318
+ scanArtifacts,
319
+ parseMcpConfigFile,
320
+ queryMcpServerTools,
321
+ };
@@ -0,0 +1,386 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+
5
+ const HOME = os.homedir();
6
+
7
+ // Discover all Claude Code config roots — ports memory-house agent-sync's
8
+ // discover_roots("claude-code"). Claude sessions don't live only under ~/.claude:
9
+ // every CLAUDE_CONFIG_DIR (e.g. Kommander playbook installs under
10
+ // ~/.claude-playbooks/<name>[/playbook]) has its own projects/ dir. A dir qualifies
11
+ // as a root if it holds a projects/ subdir or a history.jsonl. Deduped by realpath.
12
+ function discoverClaudeRoots() {
13
+ const cands = [path.join(HOME, '.claude')];
14
+ const pbBase = path.join(HOME, '.claude-playbooks');
15
+ try {
16
+ for (const name of fs.readdirSync(pbBase)) {
17
+ if (name.startsWith('.')) continue; // match glob('*') — skip dotfiles/backups (.bak)
18
+ cands.push(path.join(pbBase, name));
19
+ cands.push(path.join(pbBase, name, 'playbook')); // legacy playbook/ layout
20
+ }
21
+ } catch { /* no ~/.claude-playbooks */ }
22
+
23
+ const roots = [];
24
+ const seen = new Set();
25
+ for (const d of cands) {
26
+ let isDir = false;
27
+ try { isDir = fs.statSync(d).isDirectory(); } catch { /* missing */ }
28
+ if (!isDir) continue;
29
+ let hasProjects = false;
30
+ try { hasProjects = fs.statSync(path.join(d, 'projects')).isDirectory(); } catch { /* none */ }
31
+ const hasHistory = fs.existsSync(path.join(d, 'history.jsonl'));
32
+ if (!hasProjects && !hasHistory) continue;
33
+ let rp; try { rp = fs.realpathSync(d); } catch { rp = d; }
34
+ if (seen.has(rp)) continue;
35
+ seen.add(rp);
36
+ roots.push(d);
37
+ }
38
+ return roots;
39
+ }
40
+
41
+ // ============================================================
42
+ // Adapter interface
43
+ // ============================================================
44
+
45
+ const name = 'claude';
46
+
47
+ function getChats() {
48
+ const chats = [];
49
+
50
+ for (const root of discoverClaudeRoots()) {
51
+ const PROJECTS_DIR = path.join(root, 'projects');
52
+ if (!fs.existsSync(PROJECTS_DIR)) continue;
53
+
54
+ let projDirs;
55
+ try { projDirs = fs.readdirSync(PROJECTS_DIR); } catch { continue; }
56
+ for (const projDir of projDirs) {
57
+ const dir = path.join(PROJECTS_DIR, projDir);
58
+ try { if (!fs.statSync(dir).isDirectory()) continue; } catch { continue; }
59
+
60
+ // Decode folder path from dir name (e.g. -Users-fka-Code-foo -> /Users/fka/Code/foo)
61
+ const decodedFolder = projDir.replace(/-/g, '/');
62
+
63
+ // Read sessions-index.json for indexed sessions
64
+ const indexPath = path.join(dir, 'sessions-index.json');
65
+ const indexed = new Map();
66
+ try {
67
+ const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
68
+ for (const entry of index.entries || []) {
69
+ indexed.set(entry.sessionId, entry);
70
+ }
71
+ } catch { /* no index */ }
72
+
73
+ // Scan all .jsonl files on disk (some may not be in the index)
74
+ let files;
75
+ try { files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl')); } catch { continue; }
76
+
77
+ for (const file of files) {
78
+ const sessionId = file.replace('.jsonl', '');
79
+ const fullPath = path.join(dir, file);
80
+ const entry = indexed.get(sessionId);
81
+
82
+ if (entry) {
83
+ // Use index metadata
84
+ chats.push({
85
+ source: 'claude-code',
86
+ composerId: sessionId,
87
+ name: cleanPrompt(entry.firstPrompt),
88
+ createdAt: entry.created ? new Date(entry.created).getTime() : null,
89
+ lastUpdatedAt: entry.modified ? new Date(entry.modified).getTime() : null,
90
+ mode: 'claude',
91
+ folder: entry.projectPath || decodedFolder,
92
+ encrypted: false,
93
+ bubbleCount: entry.messageCount || 0,
94
+ _fullPath: fullPath,
95
+ _gitBranch: entry.gitBranch,
96
+ });
97
+ } else {
98
+ // Orphan .jsonl — extract metadata from file content
99
+ try {
100
+ const stat = fs.statSync(fullPath);
101
+ const meta = peekSessionMeta(fullPath);
102
+ chats.push({
103
+ source: 'claude-code',
104
+ composerId: sessionId,
105
+ name: meta.firstPrompt ? cleanPrompt(meta.firstPrompt) : null,
106
+ createdAt: meta.timestamp || stat.birthtime.getTime(),
107
+ lastUpdatedAt: stat.mtime.getTime(),
108
+ mode: 'claude',
109
+ folder: meta.cwd || decodedFolder,
110
+ encrypted: false,
111
+ _fullPath: fullPath,
112
+ });
113
+ } catch { /* skip */ }
114
+ }
115
+
116
+ // Remove from indexed so we know what's left
117
+ indexed.delete(sessionId);
118
+ }
119
+
120
+ // Add indexed sessions whose .jsonl files no longer exist (show as unavailable)
121
+ for (const [sessionId, entry] of indexed) {
122
+ if (!entry.fullPath || !fs.existsSync(entry.fullPath)) continue;
123
+ chats.push({
124
+ source: 'claude-code',
125
+ composerId: sessionId,
126
+ name: cleanPrompt(entry.firstPrompt),
127
+ createdAt: entry.created ? new Date(entry.created).getTime() : null,
128
+ lastUpdatedAt: entry.modified ? new Date(entry.modified).getTime() : null,
129
+ mode: 'claude',
130
+ folder: entry.projectPath || decodedFolder,
131
+ encrypted: false,
132
+ bubbleCount: entry.messageCount || 0,
133
+ _fullPath: entry.fullPath,
134
+ });
135
+ }
136
+ }
137
+ }
138
+
139
+ return chats;
140
+ }
141
+
142
+ function peekSessionMeta(filePath) {
143
+ const meta = { firstPrompt: null, cwd: null, timestamp: null };
144
+ try {
145
+ const buf = fs.readFileSync(filePath, 'utf-8');
146
+ for (const line of buf.split('\n')) {
147
+ if (!line) continue;
148
+ const obj = JSON.parse(line);
149
+ if (!meta.cwd && obj.cwd) meta.cwd = obj.cwd;
150
+ if (!meta.timestamp && obj.timestamp) {
151
+ meta.timestamp = typeof obj.timestamp === 'string'
152
+ ? new Date(obj.timestamp).getTime() : obj.timestamp;
153
+ }
154
+ if (!meta.firstPrompt && obj.type === 'user' && obj.message?.content) {
155
+ const text = typeof obj.message.content === 'string'
156
+ ? obj.message.content
157
+ : obj.message.content.filter(c => c.type === 'text').map(c => c.text).join(' ');
158
+ meta.firstPrompt = text.substring(0, 200);
159
+ }
160
+ if (meta.cwd && meta.firstPrompt) break;
161
+ }
162
+ } catch {}
163
+ return meta;
164
+ }
165
+
166
+ function cleanPrompt(prompt) {
167
+ if (!prompt || prompt === 'No prompt') return null;
168
+ // Strip XML tags and system-reminder blocks
169
+ let clean = prompt
170
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
171
+ .replace(/<[^>]+>/g, '')
172
+ .replace(/\s+/g, ' ')
173
+ .trim()
174
+ .substring(0, 120);
175
+ return clean || null;
176
+ }
177
+
178
+ // Parse one Claude session .jsonl into the adapter's message shape. When
179
+ // `isSubagent` is set, each message is tagged `[subagent]` so folded subagent turns
180
+ // are clearly attributed in the transcript.
181
+ function parseSessionFile(filePath, isSubagent) {
182
+ const messages = [];
183
+ let lines;
184
+ try { lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean); } catch { return messages; }
185
+ const tag = isSubagent ? '[subagent] ' : '';
186
+
187
+ for (const line of lines) {
188
+ let obj;
189
+ try { obj = JSON.parse(line); } catch { continue; }
190
+
191
+ if (obj.type === 'user' && obj.message) {
192
+ const content = extractContent(obj.message.content);
193
+ if (content) messages.push({ role: 'user', content: tag + content });
194
+ } else if (obj.type === 'assistant' && obj.message) {
195
+ const { text, toolCalls } = extractAssistantContent(obj.message.content);
196
+ const usage = obj.message.usage;
197
+ if (text) messages.push({
198
+ role: 'assistant', content: tag + text, _model: obj.message.model,
199
+ _inputTokens: usage?.input_tokens, _outputTokens: usage?.output_tokens,
200
+ _cacheRead: usage?.cache_read_input_tokens, _cacheWrite: usage?.cache_creation_input_tokens,
201
+ _toolCalls: toolCalls,
202
+ });
203
+ } else if (obj.type === 'system') {
204
+ const text = typeof obj.message?.content === 'string' ? obj.message.content : '';
205
+ if (text) messages.push({ role: 'system', content: tag + text });
206
+ }
207
+ }
208
+ return messages;
209
+ }
210
+
211
+ function getMessages(chat) {
212
+ const filePath = chat._fullPath;
213
+ if (!filePath || !fs.existsSync(filePath)) return [];
214
+
215
+ const messages = parseSessionFile(filePath, false);
216
+
217
+ // Fold in subagent transcripts — projects/<enc>/<uuid>/subagents/agent-*.jsonl.
218
+ // Their turns + token/tool usage belong to THIS parent session; appended (marked)
219
+ // after the parent's turns, so nothing is lost and subagents are never
220
+ // double-counted as standalone sessions.
221
+ const subagentsDir = path.join(filePath.replace(/\.jsonl$/, ''), 'subagents');
222
+ try {
223
+ const files = fs.readdirSync(subagentsDir).filter(f => f.endsWith('.jsonl')).sort();
224
+ for (const f of files) messages.push(...parseSessionFile(path.join(subagentsDir, f), true));
225
+ } catch { /* no subagents for this session */ }
226
+
227
+ return messages;
228
+ }
229
+
230
+ function extractContent(content) {
231
+ if (typeof content === 'string') return content;
232
+ if (!Array.isArray(content)) return '';
233
+ return content
234
+ .filter(c => c.type === 'text')
235
+ .map(c => c.text)
236
+ .join('\n') || '';
237
+ }
238
+
239
+ function extractAssistantContent(content) {
240
+ if (typeof content === 'string') return { text: content, toolCalls: [] };
241
+ if (!Array.isArray(content)) return { text: '', toolCalls: [] };
242
+ const parts = [];
243
+ const toolCalls = [];
244
+ for (const block of content) {
245
+ if (block.type === 'thinking' && block.thinking) {
246
+ parts.push(`[thinking] ${block.thinking}`);
247
+ } else if (block.type === 'text' && block.text) {
248
+ parts.push(block.text);
249
+ } else if (block.type === 'tool_use') {
250
+ const args = block.input || {};
251
+ const argKeys = Object.keys(args).join(', ');
252
+ parts.push(`[tool-call: ${block.name || 'unknown'}(${argKeys})]`);
253
+ toolCalls.push({ name: block.name || 'unknown', args });
254
+ } else if (block.type === 'tool_result') {
255
+ const text = typeof block.content === 'string' ? block.content : '';
256
+ parts.push(`[tool-result: ${block.name || 'tool'}] ${text.substring(0, 500)}`);
257
+ }
258
+ }
259
+ return { text: parts.join('\n') || '', toolCalls };
260
+ }
261
+
262
+ // ============================================================
263
+ // Usage / quota data from Anthropic OAuth API
264
+ // ============================================================
265
+
266
+ function getClaudeCredentials() {
267
+ // macOS: Keychain; Linux: secret-tool; Windows: not yet supported
268
+ // Requires explicit user permission (allowSubscriptionAccess in config)
269
+ const { isSubscriptionAccessAllowed } = require('./base');
270
+ if (!isSubscriptionAccessAllowed()) return null;
271
+ try {
272
+ const { execSync } = require('child_process');
273
+ let raw;
274
+ if (process.platform === 'darwin') {
275
+ raw = execSync('security find-generic-password -s "Claude Code-credentials" -w', { encoding: 'utf-8', timeout: 5000 }).trim();
276
+ } else if (process.platform === 'linux') {
277
+ raw = execSync('secret-tool lookup service "Claude Code-credentials"', { encoding: 'utf-8', timeout: 5000 }).trim();
278
+ } else {
279
+ return null;
280
+ }
281
+ const creds = JSON.parse(raw);
282
+ const oauth = creds.claudeAiOauth;
283
+ if (!oauth || !oauth.accessToken) return null;
284
+ return oauth;
285
+ } catch { return null; }
286
+ }
287
+
288
+ function claudeApiFetch(token) {
289
+ return new Promise((resolve) => {
290
+ const https = require('https');
291
+ const req = https.get('https://api.anthropic.com/api/oauth/usage', {
292
+ headers: {
293
+ 'Accept': 'application/json',
294
+ 'Content-Type': 'application/json',
295
+ 'User-Agent': 'agentlytics/1.0',
296
+ 'Authorization': `Bearer ${token}`,
297
+ 'anthropic-beta': 'oauth-2025-04-20',
298
+ },
299
+ timeout: 10000,
300
+ }, (res) => {
301
+ let data = '';
302
+ res.on('data', (chunk) => { data += chunk; });
303
+ res.on('end', () => {
304
+ try { resolve(JSON.parse(data)); } catch { resolve(null); }
305
+ });
306
+ });
307
+ req.on('error', () => resolve(null));
308
+ req.on('timeout', () => { req.destroy(); resolve(null); });
309
+ });
310
+ }
311
+
312
+ async function getUsage() {
313
+ const creds = getClaudeCredentials();
314
+ if (!creds) return null;
315
+
316
+ const usage = await claudeApiFetch(creds.accessToken);
317
+ if (!usage) return null;
318
+
319
+ const result = {
320
+ source: 'claude-code',
321
+ plan: {
322
+ name: creds.subscriptionType || null,
323
+ },
324
+ usage: {},
325
+ extraUsage: null,
326
+ };
327
+
328
+ if (usage.five_hour) {
329
+ result.usage.fiveHour = {
330
+ utilization: usage.five_hour.utilization,
331
+ resetsAt: usage.five_hour.resets_at || null,
332
+ };
333
+ }
334
+ if (usage.seven_day) {
335
+ result.usage.sevenDay = {
336
+ utilization: usage.seven_day.utilization,
337
+ resetsAt: usage.seven_day.resets_at || null,
338
+ };
339
+ }
340
+ if (usage.seven_day_sonnet) {
341
+ result.usage.sevenDaySonnet = {
342
+ utilization: usage.seven_day_sonnet.utilization,
343
+ resetsAt: usage.seven_day_sonnet.resets_at || null,
344
+ };
345
+ }
346
+ if (usage.seven_day_opus) {
347
+ result.usage.sevenDayOpus = {
348
+ utilization: usage.seven_day_opus.utilization,
349
+ resetsAt: usage.seven_day_opus.resets_at || null,
350
+ };
351
+ }
352
+ if (usage.extra_usage) {
353
+ result.extraUsage = {
354
+ isEnabled: usage.extra_usage.is_enabled || false,
355
+ monthlyLimit: usage.extra_usage.monthly_limit || null,
356
+ usedCredits: usage.extra_usage.used_credits || null,
357
+ utilization: usage.extra_usage.utilization || null,
358
+ };
359
+ }
360
+
361
+ return result;
362
+ }
363
+
364
+ const labels = { 'claude-code': 'Claude Code' };
365
+
366
+ function getArtifacts(folder) {
367
+ const { scanArtifacts } = require('./base');
368
+ return scanArtifacts(folder, {
369
+ editor: 'claude-code',
370
+ label: 'Claude Code',
371
+ files: ['CLAUDE.md', '.claude/settings.json', '.claude/settings.local.json', '.mcp.json'],
372
+ dirs: ['.claude/commands'],
373
+ });
374
+ }
375
+
376
+ function getMCPServers() {
377
+ const { parseMcpConfigFile } = require('./base');
378
+ const results = [];
379
+ // Global: ~/.claude.json (has mcpServers key)
380
+ const globalFile = path.join(os.homedir(), '.claude.json');
381
+ results.push(...parseMcpConfigFile(globalFile, { editor: 'claude-code', label: 'Claude Code', scope: 'global' }));
382
+ // Project-level: .mcp.json (scanned per-project later via getAllMCPServers)
383
+ return results;
384
+ }
385
+
386
+ module.exports = { name, labels, getChats, getMessages, getUsage, getArtifacts, getMCPServers };